mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
Merge branch 'main' into wifi-aware-refactor-mesh-core
# Conflicts: # app/src/main/AndroidManifest.xml # app/src/main/java/com/bitchat/android/BitchatApplication.kt # app/src/main/java/com/bitchat/android/MainActivity.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt # app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt # app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt # app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Bitchat Android - Agent Guide
|
||||
|
||||
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
|
||||
|
||||
## 1. Project Overview
|
||||
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
|
||||
|
||||
**Key Technologies:**
|
||||
- **Language:** Kotlin (JVM Target 1.8)
|
||||
- **UI Framework:** Jetpack Compose (Material 3)
|
||||
- **Asynchronous:** Kotlin Coroutines & Flow
|
||||
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
|
||||
- **Architecture:** MVVM with Clean Architecture principles
|
||||
- **Build System:** Gradle (Kotlin DSL)
|
||||
|
||||
## 2. Architecture & Directory Structure
|
||||
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
|
||||
|
||||
**Root Package:** `com.bitchat.android`
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
|
||||
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
|
||||
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
|
||||
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
|
||||
| `crypto/` | **Security**: Cryptographic primitives and key management. |
|
||||
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
|
||||
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
|
||||
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
|
||||
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
|
||||
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
|
||||
| `net/` | **Networking**: General network utilities and abstractions. |
|
||||
|
||||
## 3. Key Components
|
||||
|
||||
### UI Layer (Jetpack Compose)
|
||||
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
|
||||
- **Navigation**: Jetpack Compose Navigation.
|
||||
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
|
||||
- **Theme**: Custom theme definitions in `ui/theme`.
|
||||
|
||||
### Networking & Connectivity
|
||||
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
|
||||
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
|
||||
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
|
||||
|
||||
## 4. Development Standards
|
||||
|
||||
### Code Style
|
||||
- **Kotlin**: Adhere to official Kotlin coding conventions.
|
||||
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
|
||||
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
|
||||
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
|
||||
|
||||
### Testing
|
||||
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
|
||||
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
|
||||
- **Execution**:
|
||||
- Unit: `./gradlew test`
|
||||
- Instrumented: `./gradlew connectedAndroidTest`
|
||||
|
||||
## 5. Critical Constraints & Gotchas
|
||||
1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features.
|
||||
2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky.
|
||||
3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`.
|
||||
|
||||
## 6. Common Tasks
|
||||
- **Build Debug APK**: `./gradlew assembleDebug`
|
||||
- **Lint Check**: `./gradlew lint`
|
||||
- **Clean Build**: `./gradlew clean`
|
||||
|
||||
---
|
||||
*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.*
|
||||
+670
-17
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2025
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
+22
-7
@@ -8,7 +8,10 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
|
||||
## Summary
|
||||
|
||||
**WE DO NOT COLLECT ANY INFORMATION.**
|
||||
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
|
||||
- **Hybrid Functionality** - bitchat offers two modes of communication:
|
||||
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
|
||||
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
|
||||
@@ -68,7 +71,8 @@ When you join a password-protected room:
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Tracks your location
|
||||
- Collects location history
|
||||
- Transmits any data to us (the developers)
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Uses analytics or telemetry
|
||||
@@ -91,13 +95,24 @@ You have complete control:
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
|
||||
## Bluetooth & Permissions
|
||||
## Location Data & Permissions
|
||||
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- No location data is accessed or stored
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
|
||||
|
||||
### 1. Bluetooth Low Energy (BLE) Scanning
|
||||
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
|
||||
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
|
||||
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
|
||||
|
||||
### 2. Geohash Chat Functionality
|
||||
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
|
||||
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
|
||||
- **Privacy protection:**
|
||||
- Your precise GPS coordinates are **never** sent to any server or peer.
|
||||
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
|
||||
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
|
||||
|
||||
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
|
||||
+32
-8
@@ -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)
|
||||
|
||||
Vendored
+5
@@ -26,3 +26,8 @@
|
||||
-keepnames class org.torproject.arti.**
|
||||
-dontwarn info.guardianproject.arti.**
|
||||
-dontwarn org.torproject.arti.**
|
||||
|
||||
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
|
||||
-keepclassmembers class * implements android.location.LocationListener {
|
||||
public <methods>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<!-- Location permission required for BLE scanning -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<!-- Notification permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -34,10 +35,14 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<!-- Data sync foreground service type (required when declaring dataSync) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Location foreground service type required for BLE scanning in background -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Microphone for voice notes -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Camera for QR verification -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Storage permissions for file sharing -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
@@ -56,6 +61,7 @@
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
<!-- Device support hint for Wi‑Fi Aware (optional) -->
|
||||
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<permission
|
||||
android:name="com.bitchat.android.permission.FORCE_FINISH"
|
||||
@@ -98,13 +104,19 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="bitchat" android:host="verify" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Persistent foreground service to run the mesh in background -->
|
||||
<service
|
||||
android:name=".service.MeshForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|dataSync"
|
||||
android:foregroundServiceType="connectedDevice|dataSync|location"
|
||||
tools:ignore="DataExtractionRules">
|
||||
</service>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -46,6 +46,13 @@ class BitchatApplication : Application() {
|
||||
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
|
||||
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Initialize Geohash Registries for persistence
|
||||
try {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
||||
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
|
||||
import com.bitchat.android.onboarding.InitializationErrorScreen
|
||||
import com.bitchat.android.onboarding.InitializingScreen
|
||||
import com.bitchat.android.onboarding.LocationCheckScreen
|
||||
@@ -42,6 +43,7 @@ import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import com.bitchat.android.wifiaware.WifiAwareMeshDelegate
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -170,6 +172,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
activity = this,
|
||||
permissionManager = permissionManager,
|
||||
onOnboardingComplete = ::handleOnboardingComplete,
|
||||
onBackgroundLocationRequired = {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
},
|
||||
onOnboardingFailed = ::handleOnboardingFailed
|
||||
)
|
||||
|
||||
@@ -318,6 +323,21 @@ class MainActivity : OrientationAwareActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
|
||||
BackgroundLocationPermissionScreen(
|
||||
modifier = modifier,
|
||||
onContinue = {
|
||||
onboardingCoordinator.requestBackgroundLocation()
|
||||
},
|
||||
onRetry = {
|
||||
onboardingCoordinator.checkBackgroundLocationAndProceed()
|
||||
},
|
||||
onSkip = {
|
||||
onboardingCoordinator.skipBackgroundLocation()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
|
||||
// Set up back navigation handling for the chat screen
|
||||
val backCallback = object : OnBackPressedCallback(true) {
|
||||
@@ -444,10 +464,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with permissions, initializing app")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with required permissions")
|
||||
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
|
||||
) {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
} else {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
@@ -723,6 +750,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
// Handle any notification intent
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
|
||||
// Small delay to ensure mesh service is fully initialized
|
||||
delay(500)
|
||||
@@ -751,6 +779,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Handle notification intents when app is already running
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,9 +790,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
try { WifiAwareController.getService()?.delegate = wifiAwareDelegate } catch (_: Exception) { }
|
||||
// Set app foreground state
|
||||
meshService.connectionManager.setAppBackgroundState(false)
|
||||
chatViewModel.setAppBackgroundState(false)
|
||||
|
||||
// Check if Bluetooth was disabled while app was backgrounded
|
||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
@@ -793,9 +819,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
super.onPause()
|
||||
// Only set background state if app is fully initialized
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app background state
|
||||
meshService.connectionManager.setAppBackgroundState(true)
|
||||
chatViewModel.setAppBackgroundState(true)
|
||||
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
||||
try { meshService.delegate = null } catch (_: Exception) { }
|
||||
try { WifiAwareController.getService()?.delegate = null } catch (_: Exception) { }
|
||||
@@ -824,8 +847,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (peerID != null) {
|
||||
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||
|
||||
// Open the private chat with this peer
|
||||
chatViewModel.startPrivateChat(peerID)
|
||||
// Open the private chat sheet with this peer
|
||||
chatViewModel.showMeshPeerList()
|
||||
chatViewModel.showPrivateChatSheet(peerID)
|
||||
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
chatViewModel.clearNotificationsForSender(peerID)
|
||||
@@ -861,6 +885,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVerificationIntent(intent: Intent) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return
|
||||
|
||||
chatViewModel.showVerificationSheet()
|
||||
val qr = VerificationService.verifyScannedQR(uri.toString())
|
||||
if (qr != null) {
|
||||
chatViewModel.beginQRVerification(qr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.bitchat.android.core.ui.component.sheet
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatBottomSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.bitchat.android.core.ui.component.sheet
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatSheetTopBar(
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlpha: Float = 0.98f,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
TopAppBar(
|
||||
title = title,
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatSheetCenterTopBar(
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlpha: Float = 0.98f,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BitchatSheetTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.bitchat.android.crypto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.bitchat.android.noise.NoiseEncryptionService
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
|
||||
@@ -11,6 +15,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Encryption service that now uses NoiseEncryptionService internally
|
||||
@@ -19,29 +24,55 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* This is the main interface for all encryption/decryption operations in bitchat.
|
||||
* It now uses the Noise protocol for secure transport encryption with proper session management.
|
||||
*/
|
||||
class EncryptionService(private val context: Context) {
|
||||
open class EncryptionService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EncryptionService"
|
||||
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
|
||||
private const val OLD_PREFS_NAME = "bitchat_crypto"
|
||||
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
|
||||
}
|
||||
|
||||
// Core Noise encryption service
|
||||
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
|
||||
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
|
||||
|
||||
// Session tracking for established connections
|
||||
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
||||
|
||||
// Ed25519 signing keys (separate from Noise static keys)
|
||||
private val ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private val ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
|
||||
// Callbacks for UI state updates
|
||||
var onSessionEstablished: ((String) -> Unit)? = null // peerID
|
||||
var onSessionLost: ((String) -> Unit)? = null // peerID
|
||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
initialize()
|
||||
}
|
||||
|
||||
private fun setUpEncryptedPrefs() {
|
||||
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
// Create encrypted shared preferences
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
SECURE_PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialization logic moved to method to allow overriding in tests
|
||||
*/
|
||||
protected open fun initialize() {
|
||||
setUpEncryptedPrefs()
|
||||
// Initialize or load Ed25519 signing keys
|
||||
val keyPair = loadOrCreateEd25519KeyPair()
|
||||
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
@@ -135,9 +166,14 @@ class EncryptionService(private val context: Context) {
|
||||
|
||||
// Clear Ed25519 signing key from preferences
|
||||
try {
|
||||
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
|
||||
prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply()
|
||||
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) }
|
||||
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
|
||||
|
||||
// Generate new keys immediately
|
||||
val keyPair = loadOrCreateEd25519KeyPair()
|
||||
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
|
||||
}
|
||||
@@ -356,7 +392,7 @@ class EncryptionService(private val context: Context) {
|
||||
/**
|
||||
* Verify Ed25519 signature against data using a public key
|
||||
*/
|
||||
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
return try {
|
||||
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
|
||||
val verifier = Ed25519Signer()
|
||||
@@ -377,13 +413,14 @@ class EncryptionService(private val context: Context) {
|
||||
* Load existing Ed25519 key pair from preferences or create a new one
|
||||
*/
|
||||
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
|
||||
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
|
||||
migrateOldEd25519KeyIfNeeded()
|
||||
try {
|
||||
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
|
||||
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
|
||||
|
||||
|
||||
if (storedKey != null) {
|
||||
// Load existing key
|
||||
val privateKeyBytes = android.util.Base64.decode(storedKey, android.util.Base64.DEFAULT)
|
||||
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT)
|
||||
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
|
||||
val publicKey = privateKey.generatePublicKey()
|
||||
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
|
||||
@@ -394,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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,9 @@ object FileUtils {
|
||||
): String {
|
||||
val lowerMime = file.mimeType.lowercase()
|
||||
val isImage = lowerMime.startsWith("image/")
|
||||
val baseDir = context.filesDir
|
||||
// FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592)
|
||||
// Files in cacheDir are eligible for automatic system cleanup when space is low
|
||||
val baseDir = context.cacheDir
|
||||
val subdir = if (isImage) "images/incoming" else "files/incoming"
|
||||
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
|
||||
|
||||
@@ -271,4 +273,54 @@ object FileUtils {
|
||||
else -> com.bitchat.android.model.BitchatMessageType.File
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete all media files (incoming and outgoing)
|
||||
* Used for Panic Mode cleanup
|
||||
*/
|
||||
fun clearAllMedia(context: Context) {
|
||||
try {
|
||||
// Clear files dir subdirectories (legacy storage and outgoing)
|
||||
val filesDir = context.filesDir
|
||||
val dirsToClear = listOf(
|
||||
"files/incoming",
|
||||
"files/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"voicenotes"
|
||||
)
|
||||
|
||||
dirsToClear.forEach { subDir ->
|
||||
val dir = File(filesDir, subDir)
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache dir subdirectories (new incoming storage)
|
||||
// Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these
|
||||
// specific media folders are targeted even if full cache clear fails or is modified later.
|
||||
val cacheDir = context.cacheDir
|
||||
val cacheDirsToClear = listOf(
|
||||
"files/incoming",
|
||||
"images/incoming"
|
||||
)
|
||||
|
||||
cacheDirsToClear.forEach { subDir ->
|
||||
val dir = File(cacheDir, subDir)
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear entire cache dir as a catch-all
|
||||
context.cacheDir.deleteRecursively()
|
||||
Log.d(TAG, "Cleared entire cache directory")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear media files", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Address
|
||||
import android.location.Geocoder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
|
||||
private val geocoder = Geocoder(context, Locale.getDefault())
|
||||
private val TAG = "AndroidGeocoderProvider"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
geocoder.getFromLocation(
|
||||
latitude,
|
||||
longitude,
|
||||
maxResults,
|
||||
object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: MutableList<Address>) {
|
||||
if (cont.isActive) cont.resume(addresses)
|
||||
}
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
if (cont.isActive) {
|
||||
Log.e(TAG, "Geocode error: $errorMessage")
|
||||
cont.resume(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (cont.isActive) cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
try {
|
||||
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Geocode failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.location.*
|
||||
|
||||
class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FusedLocationProvider"
|
||||
}
|
||||
|
||||
private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
|
||||
|
||||
// Map to keep track of callbacks to remove them later
|
||||
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting last known fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val request = CurrentLocationRequest.Builder()
|
||||
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||
.setDurationMillis(30000)
|
||||
.build()
|
||||
|
||||
fusedLocationClient.getCurrentLocation(request, null)
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestLocationUpdates(
|
||||
intervalMs: Long,
|
||||
minDistanceMeters: Float,
|
||||
callback: (Location) -> Unit
|
||||
) {
|
||||
if (!hasLocationPermission()) return
|
||||
|
||||
try {
|
||||
val request = LocationRequest.Builder(intervalMs)
|
||||
.setMinUpdateDistanceMeters(minDistanceMeters)
|
||||
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||
.build()
|
||||
|
||||
val locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
result.lastLocation?.let { callback(it) }
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(activeCallbacks) {
|
||||
activeCallbacks[callback] = locationCallback
|
||||
}
|
||||
|
||||
fusedLocationClient.requestLocationUpdates(
|
||||
request,
|
||||
locationCallback,
|
||||
Looper.getMainLooper()
|
||||
)
|
||||
Log.d(TAG, "Registered fused updates")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fused updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||
try {
|
||||
val locationCallback = synchronized(activeCallbacks) {
|
||||
activeCallbacks.remove(callback)
|
||||
}
|
||||
|
||||
if (locationCallback != null) {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
Log.d(TAG, "Removed fused updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing fused updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
try {
|
||||
synchronized(activeCallbacks) {
|
||||
for ((callback, locationCallback) in activeCallbacks) {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
}
|
||||
activeCallbacks.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all fused updates")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
|
||||
/**
|
||||
* Factory to provide the best available geocoder.
|
||||
*/
|
||||
object GeocoderFactory {
|
||||
fun get(context: Context): GeocoderProvider {
|
||||
// If Google Play Services Geocoder is present, use it.
|
||||
// Otherwise, fall back to OpenStreetMap.
|
||||
return if (Geocoder.isPresent()) {
|
||||
AndroidGeocoderProvider(context)
|
||||
} else {
|
||||
OpenStreetMapGeocoderProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Address
|
||||
|
||||
/**
|
||||
* Interface for reverse geocoding providers.
|
||||
*/
|
||||
interface GeocoderProvider {
|
||||
/**
|
||||
* Get a list of Address objects from latitude and longitude.
|
||||
*/
|
||||
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
|
||||
}
|
||||
@@ -167,12 +167,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
if (gh.isEmpty()) return
|
||||
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||
if (resolving.contains(gh)) return
|
||||
if (!Geocoder.isPresent()) return
|
||||
|
||||
resolving.add(gh)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val geocoder = Geocoder(context, Locale.getDefault())
|
||||
val geocoderProvider = GeocoderFactory.get(context)
|
||||
val name: String? = if (gh.length <= 2) {
|
||||
// Composite admin name from multiple points
|
||||
val b = Geohash.decodeToBounds(gh)
|
||||
@@ -186,9 +185,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
val admins = linkedSetOf<String>()
|
||||
for (loc in points) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list.firstOrNull()
|
||||
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||
if (admin != null) admins.add(admin)
|
||||
@@ -203,9 +201,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
}
|
||||
} else {
|
||||
val center = Geohash.decodeToCenter(gh)
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(center.first, center.second, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
|
||||
val a = list.firstOrNull()
|
||||
pickNameForLength(gh.length, a)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,18 @@ data class GeohashChannel(
|
||||
*/
|
||||
sealed class ChannelID {
|
||||
object Mesh : ChannelID()
|
||||
data class Location(val channel: GeohashChannel) : ChannelID()
|
||||
data class Location(val channel: GeohashChannel) : ChannelID() {
|
||||
companion object {
|
||||
fun fromPersisted(levelName: String, geohash: String): Location? {
|
||||
return try {
|
||||
val level = GeohashChannelLevel.valueOf(levelName)
|
||||
Location(GeohashChannel(level, geohash))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human readable name for UI.
|
||||
|
||||
@@ -2,20 +2,25 @@ package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
@@ -38,22 +43,45 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
// State enum matching iOS
|
||||
enum class PermissionState {
|
||||
NOT_DETERMINED,
|
||||
DENIED,
|
||||
RESTRICTED,
|
||||
AUTHORIZED
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
|
||||
private val locationProvider: LocationProvider
|
||||
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
|
||||
private var lastLocation: Location? = null
|
||||
private var refreshTimer: Job? = null
|
||||
private var isGeocoding: Boolean = false
|
||||
private var geocodingJob: Job? = null
|
||||
private val gson = Gson()
|
||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||
|
||||
private fun checkSystemLocationEnabled(): Boolean {
|
||||
return try {
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val locationStateReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: android.content.Intent?) {
|
||||
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
|
||||
val isEnabled = checkSystemLocationEnabled()
|
||||
Log.d(TAG, "System location state changed: $isEnabled")
|
||||
_systemLocationEnabled.value = isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationUpdateCallback: (Location) -> Unit = { location ->
|
||||
onLocationUpdated(location)
|
||||
}
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val _permissionState = MutableStateFlow(PermissionState.DENIED)
|
||||
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||
|
||||
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
|
||||
@@ -74,12 +102,40 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private val _locationServicesEnabled = MutableStateFlow(false)
|
||||
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
|
||||
|
||||
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
|
||||
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
|
||||
|
||||
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
|
||||
locationServicesEnabled,
|
||||
systemLocationEnabled
|
||||
) { appToggle, systemToggle ->
|
||||
appToggle && systemToggle
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
false
|
||||
)
|
||||
|
||||
|
||||
init {
|
||||
updatePermissionState()
|
||||
// Choose the best location provider
|
||||
val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
|
||||
locationProvider = if (availability == ConnectionResult.SUCCESS) {
|
||||
Log.i(TAG, "Using FusedLocationProvider (Google Play Services)")
|
||||
FusedLocationProvider(context)
|
||||
} else {
|
||||
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
|
||||
SystemLocationProvider(context)
|
||||
}
|
||||
|
||||
checkAndSyncPermission()
|
||||
// Initialize DataManager and load persisted settings
|
||||
dataManager = com.bitchat.android.ui.DataManager(context)
|
||||
loadPersistedChannelSelection()
|
||||
loadLocationServicesState()
|
||||
|
||||
// Register for system location changes
|
||||
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
|
||||
}
|
||||
|
||||
// MARK: - Public API (matching iOS interface)
|
||||
@@ -91,26 +147,19 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
fun enableLocationChannels() {
|
||||
Log.d(TAG, "enableLocationChannels() called")
|
||||
|
||||
// UNIFIED FIX: Check if location services are enabled by user
|
||||
if (!isLocationServicesEnabled()) {
|
||||
Log.w(TAG, "Location services disabled by user - not requesting location")
|
||||
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
|
||||
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
|
||||
return
|
||||
}
|
||||
|
||||
when (getCurrentPermissionStatus()) {
|
||||
PermissionState.NOT_DETERMINED -> {
|
||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||
_permissionState.value = PermissionState.NOT_DETERMINED
|
||||
}
|
||||
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
||||
Log.d(TAG, "Permission denied or restricted")
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
PermissionState.AUTHORIZED -> {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
} else {
|
||||
Log.d(TAG, "Permission not granted")
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,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<String, Any>
|
||||
if (channelMap != null) {
|
||||
val channel = when (channelMap["type"] as? String) {
|
||||
"mesh" -> ChannelID.Mesh
|
||||
"location" -> {
|
||||
val levelName = channelMap["level"] as? String
|
||||
val precision = (channelMap["precision"] as? Double)?.toInt()
|
||||
val geohash = channelMap["geohash"] as? String
|
||||
val displayName = channelMap["displayName"] as? String
|
||||
|
||||
if (levelName != null && precision != null && geohash != null && displayName != null) {
|
||||
try {
|
||||
val level = GeohashChannelLevel.valueOf(levelName)
|
||||
val geohashChannel = GeohashChannel(level, geohash)
|
||||
ChannelID.Location(geohashChannel)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Invalid geohash level in persisted data: $levelName")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Incomplete location channel data in persistence")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
if (!channelData.isNullOrBlank()) {
|
||||
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
|
||||
val channel = persisted?.toChannel()
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.w(TAG, "Invalid channel data format in persistence")
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} else {
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Location
|
||||
|
||||
/**
|
||||
* Abstraction for location providers to support both
|
||||
* System (LocationManager) and Google Play Services (FusedLocationProvider).
|
||||
*/
|
||||
interface LocationProvider {
|
||||
/**
|
||||
* Get the last known location from cache.
|
||||
* @param callback Called with the location or null if not found/error.
|
||||
*/
|
||||
fun getLastKnownLocation(callback: (Location?) -> Unit)
|
||||
|
||||
/**
|
||||
* Request a single, fresh location update.
|
||||
* @param callback Called with the location or null if failed.
|
||||
*/
|
||||
fun requestFreshLocation(callback: (Location?) -> Unit)
|
||||
|
||||
/**
|
||||
* Request continuous location updates.
|
||||
* @param intervalMs Desired interval in milliseconds.
|
||||
* @param minDistanceMeters Minimum distance in meters.
|
||||
* @param callback Called when location updates.
|
||||
*/
|
||||
fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit)
|
||||
|
||||
/**
|
||||
* Stop location updates.
|
||||
* @param callback The same callback instance passed to requestLocationUpdates.
|
||||
*/
|
||||
fun removeLocationUpdates(callback: (Location) -> Unit)
|
||||
|
||||
/**
|
||||
* Cancel any pending one-shot location requests and cleanup resources.
|
||||
*/
|
||||
fun cancel()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Address
|
||||
import android.util.Log
|
||||
import com.bitchat.android.net.OkHttpProvider
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.Request
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OpenStreetMapGeocoderProvider : GeocoderProvider {
|
||||
private val TAG = "OSMGeocoderProvider"
|
||||
private val gson = Gson()
|
||||
private val userAgent = "Bitchat-Android/1.0"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val lang = Locale.getDefault().toLanguageTag()
|
||||
// Using format=jsonv2 for structured address breakdown
|
||||
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val response = OkHttpProvider.httpClient().newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "OSM Request failed: ${response.code}")
|
||||
response.close()
|
||||
return@withContext emptyList<Address>()
|
||||
}
|
||||
|
||||
val body = response.body?.string()
|
||||
response.close()
|
||||
|
||||
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
|
||||
|
||||
try {
|
||||
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
|
||||
if (osmResponse?.address == null) return@withContext emptyList<Address>()
|
||||
|
||||
val address = mapToAddress(osmResponse, latitude, longitude)
|
||||
listOf(address)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Parse failed: ${e.message}")
|
||||
emptyList<Address>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Geocoding failed", e)
|
||||
emptyList<Address>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
|
||||
val address = Address(Locale.getDefault())
|
||||
address.latitude = lat
|
||||
address.longitude = lon
|
||||
|
||||
val a = res.address ?: return address
|
||||
|
||||
address.countryName = a.country
|
||||
address.adminArea = a.state
|
||||
address.subAdminArea = a.county
|
||||
|
||||
// City logic similar to Google's mapping
|
||||
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
|
||||
|
||||
// Neighborhood logic
|
||||
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
|
||||
|
||||
address.postalCode = a.postcode
|
||||
address.thoroughfare = a.road
|
||||
|
||||
// Feature name
|
||||
address.featureName = res.name
|
||||
|
||||
return address
|
||||
}
|
||||
|
||||
// Data classes for JSON parsing
|
||||
private data class OsmResponse(
|
||||
val name: String?,
|
||||
val display_name: String?,
|
||||
val address: OsmAddress?
|
||||
)
|
||||
|
||||
private data class OsmAddress(
|
||||
val country: String?,
|
||||
val state: String?,
|
||||
val county: String?,
|
||||
val city: String?,
|
||||
val town: String?,
|
||||
val village: String?,
|
||||
val hamlet: String?,
|
||||
val suburb: String?,
|
||||
val neighbourhood: String?,
|
||||
val residential: String?,
|
||||
val quarter: String?,
|
||||
val postcode: String?,
|
||||
val road: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemLocationProvider"
|
||||
}
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
// Map to keep track of listeners to unregister them later
|
||||
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
|
||||
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
|
||||
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
var bestLocation: Location? = null
|
||||
val providers = locationManager.getProviders(true)
|
||||
for (provider in providers) {
|
||||
val location = locationManager.getLastKnownLocation(provider)
|
||||
if (location != null) {
|
||||
if (bestLocation == null || location.time > bestLocation.time) {
|
||||
bestLocation = location
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(bestLocation)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting last known location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val providers = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
LocationManager.PASSIVE_PROVIDER
|
||||
)
|
||||
|
||||
var providerFound = false
|
||||
for (provider in providers) {
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
Log.d(TAG, "Requesting fresh location from $provider")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
null,
|
||||
context.mainExecutor
|
||||
) { location ->
|
||||
callback(location)
|
||||
}
|
||||
} else {
|
||||
// For older versions, use requestSingleUpdate with timeout mechanism
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Location request timed out")
|
||||
synchronized(activeOneShotListeners) {
|
||||
val listener = activeOneShotListeners.remove(callback)
|
||||
activeOneShotRunnables.remove(callback)
|
||||
if (listener != null) {
|
||||
try {
|
||||
locationManager.removeUpdates(listener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing timed out listener: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(null)
|
||||
}
|
||||
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
synchronized(activeOneShotListeners) {
|
||||
activeOneShotListeners.remove(callback)
|
||||
val runnable = activeOneShotRunnables.remove(callback)
|
||||
if (runnable != null) {
|
||||
handler.removeCallbacks(runnable)
|
||||
}
|
||||
}
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates in callback: ${e.message}")
|
||||
}
|
||||
callback(location)
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
override fun onProviderDisabled(provider: String) {}
|
||||
}
|
||||
|
||||
synchronized(activeOneShotListeners) {
|
||||
activeOneShotListeners[callback] = listener
|
||||
activeOneShotRunnables[callback] = timeoutRunnable
|
||||
}
|
||||
|
||||
locationManager.requestSingleUpdate(provider, listener, null)
|
||||
handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout
|
||||
}
|
||||
providerFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerFound) {
|
||||
Log.w(TAG, "No location providers available for fresh location")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fresh location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestLocationUpdates(
|
||||
intervalMs: Long,
|
||||
minDistanceMeters: Float,
|
||||
callback: (Location) -> Unit
|
||||
) {
|
||||
if (!hasLocationPermission()) return
|
||||
|
||||
try {
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
callback(location)
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
override fun onProviderDisabled(provider: String) {}
|
||||
}
|
||||
|
||||
// Store the listener so we can remove it later
|
||||
synchronized(activeListeners) {
|
||||
activeListeners[callback] = listener
|
||||
}
|
||||
|
||||
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
var registered = false
|
||||
|
||||
for (provider in providers) {
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
intervalMs,
|
||||
minDistanceMeters,
|
||||
listener
|
||||
)
|
||||
registered = true
|
||||
Log.d(TAG, "Registered updates for $provider")
|
||||
}
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
Log.w(TAG, "No providers enabled for continuous updates")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting location updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||
try {
|
||||
val listener = synchronized(activeListeners) {
|
||||
activeListeners.remove(callback)
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
locationManager.removeUpdates(listener)
|
||||
Log.d(TAG, "Removed location updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
try {
|
||||
// Cancel continuous updates
|
||||
synchronized(activeListeners) {
|
||||
for ((_, listener) in activeListeners) {
|
||||
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||
}
|
||||
activeListeners.clear()
|
||||
}
|
||||
|
||||
// Cancel one-shot requests
|
||||
synchronized(activeOneShotListeners) {
|
||||
for ((_, listener) in activeOneShotListeners) {
|
||||
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||
}
|
||||
activeOneShotListeners.clear()
|
||||
|
||||
for ((_, runnable) in activeOneShotRunnables) {
|
||||
handler.removeCallbacks(runnable)
|
||||
}
|
||||
activeOneShotRunnables.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all system location requests")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling system provider: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.MessageDigest
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
|
||||
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
|
||||
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
|
||||
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
|
||||
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
|
||||
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
|
||||
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
|
||||
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
|
||||
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
private val lock = Any()
|
||||
|
||||
init {
|
||||
// Create master key for encryption
|
||||
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
fun generateFingerprint(publicKeyData: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(publicKeyData)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
return hash.hexEncodedString()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<String> {
|
||||
return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
fun isVerifiedFingerprint(fingerprint: String): Boolean {
|
||||
return getVerifiedFingerprints().contains(fingerprint)
|
||||
}
|
||||
|
||||
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
if (verified) {
|
||||
current.add(fingerprint)
|
||||
} else {
|
||||
current.remove(fingerprint)
|
||||
}
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedPeerFingerprint(peerID: String): String? {
|
||||
val pid = peerID.lowercase()
|
||||
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
|
||||
// if we are paranoid, but SharedPreferences is generally thread-safe for reads.
|
||||
// However, to ensure we don't read a partial update (unlikely with SP), we can leave it.
|
||||
// The critical part is the write.
|
||||
val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null
|
||||
return entry.substringAfter(':').takeIf { isValidFingerprint(it) }
|
||||
}
|
||||
|
||||
fun cachePeerFingerprint(peerID: String, fingerprint: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
val pid = peerID.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$pid:") }
|
||||
current.add("$pid:$fingerprint")
|
||||
prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedNoiseKey(peerID: String): String? {
|
||||
val pid = peerID.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null
|
||||
return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) }
|
||||
}
|
||||
|
||||
fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) {
|
||||
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
|
||||
val pid = peerID.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$pid=") }
|
||||
current.add("$pid=${noiseKeyHex.lowercase()}")
|
||||
prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedNoiseFingerprint(noiseKeyHex: String): String? {
|
||||
val key = noiseKeyHex.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
|
||||
return entry.substringAfter('=').takeIf { isValidFingerprint(it) }
|
||||
}
|
||||
|
||||
fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
|
||||
val key = noiseKeyHex.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$key=") }
|
||||
current.add("$key=$fingerprint")
|
||||
prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedFingerprintNickname(fingerprint: String): String? {
|
||||
if (!isValidFingerprint(fingerprint)) return null
|
||||
val key = fingerprint.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
|
||||
val encoded = entry.substringAfter('=')
|
||||
return runCatching {
|
||||
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
String(bytes, Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun cacheFingerprintNickname(fingerprint: String, nickname: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
val key = fingerprint.lowercase()
|
||||
val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$key=") }
|
||||
current.add("$key=$encoded")
|
||||
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Rotation Management (removed)
|
||||
// Android now derives peer ID from the persisted Noise identity fingerprint.
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
/**
|
||||
* Power-optimized Bluetooth connection manager with comprehensive memory management
|
||||
@@ -37,7 +38,7 @@ class BluetoothConnectionManager(
|
||||
// Component managers
|
||||
private val permissionManager = BluetoothPermissionManager(context)
|
||||
private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager)
|
||||
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager)
|
||||
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, myPeerID)
|
||||
|
||||
// Delegate for component managers to call back to main manager
|
||||
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
|
||||
@@ -57,6 +58,8 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(device: BluetoothDevice) {
|
||||
// Trigger limit enforcement immediately upon any new connection
|
||||
enforceStrictLimits()
|
||||
delegate?.onDeviceConnected(device)
|
||||
}
|
||||
|
||||
@@ -70,7 +73,7 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
private val serverManager = BluetoothGattServerManager(
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
|
||||
)
|
||||
private val clientManager = BluetoothGattClientManager(
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
|
||||
@@ -85,10 +88,6 @@ class BluetoothConnectionManager(
|
||||
// Public property for address-peer mapping
|
||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
||||
|
||||
// Expose first-announce helpers to higher layers
|
||||
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
|
||||
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
|
||||
|
||||
init {
|
||||
powerManager.delegate = this
|
||||
// Observe debug settings to enforce role state while active
|
||||
@@ -107,30 +106,58 @@ class BluetoothConnectionManager(
|
||||
if (enabled) startClient() else stopClient()
|
||||
}
|
||||
}
|
||||
// Connection caps: enforce on change
|
||||
|
||||
// Centralized limit enforcement on any setting change
|
||||
connectionScope.launch {
|
||||
dbg.maxConnectionsOverall.collect {
|
||||
if (!isActive) return@collect
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
// Also enforce server side best-effort
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.maxClientConnections.collect {
|
||||
if (!isActive) return@collect
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.maxServerConnections.collect {
|
||||
if (!isActive) return@collect
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
combine(
|
||||
dbg.maxConnectionsOverall,
|
||||
dbg.maxServerConnections,
|
||||
dbg.maxClientConnections
|
||||
) { _, _, _ ->
|
||||
// We don't need the values here, we just need to trigger enforcement
|
||||
Unit
|
||||
}.collect {
|
||||
if (isActive) {
|
||||
enforceStrictLimits()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized connection limit enforcement
|
||||
*/
|
||||
private fun enforceStrictLimits() {
|
||||
if (!isActive) return
|
||||
|
||||
try {
|
||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
val maxOverall = dbg.maxConnectionsOverall.value
|
||||
val maxServer = dbg.maxServerConnections.value
|
||||
val maxClient = dbg.maxClientConnections.value
|
||||
|
||||
// Get list of connections to evict to satisfy all constraints
|
||||
val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient)
|
||||
|
||||
if (toEvict.isNotEmpty()) {
|
||||
Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections")
|
||||
|
||||
toEvict.forEach { conn ->
|
||||
if (conn.isClient) {
|
||||
Log.d(TAG, "Evicting client ${conn.device.address}")
|
||||
try { conn.gatt?.disconnect() } catch (_: Exception) { }
|
||||
} else {
|
||||
Log.d(TAG, "Evicting server ${conn.device.address}")
|
||||
serverManager.disconnectDevice(conn.device)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error enforcing limits: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all Bluetooth services with power optimization
|
||||
*/
|
||||
@@ -247,13 +274,6 @@ class BluetoothConnectionManager(
|
||||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app background state for power optimization
|
||||
*/
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
powerManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast packet to connected devices with connection limit enforcement
|
||||
* Automatically fragments large packets to fit within BLE MTU limits
|
||||
@@ -268,6 +288,16 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
peerID,
|
||||
routed,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
return packetBroadcaster.cancelTransfer(transferId)
|
||||
}
|
||||
@@ -392,12 +422,7 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
// Enforce connection limits
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
// Best-effort server cap
|
||||
try {
|
||||
val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value
|
||||
serverManager.enforceServerLimit(maxServer)
|
||||
} catch (_: Exception) { }
|
||||
enforceStrictLimits()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class BluetoothConnectionTracker(
|
||||
val addressPeerMap = ConcurrentHashMap<String, String>()
|
||||
// Track whether we have seen the first ANNOUNCE on a given device connection
|
||||
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
// RSSI tracking from scan results (for devices we discover but may connect as servers)
|
||||
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
||||
|
||||
@@ -42,7 +41,8 @@ class BluetoothConnectionTracker(
|
||||
val characteristic: BluetoothGattCharacteristic? = null,
|
||||
val rssi: Int = Int.MIN_VALUE,
|
||||
val isClient: Boolean = false,
|
||||
val connectedAt: Long = System.currentTimeMillis()
|
||||
val connectedAt: Long = System.currentTimeMillis(),
|
||||
val peerID: String? = null
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
@@ -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<DeviceConnection> {
|
||||
val toEvict = mutableSetOf<DeviceConnection>()
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -320,6 +320,22 @@ class BluetoothGattClientManager(
|
||||
return
|
||||
}
|
||||
|
||||
// Try to extract peerID from Service Data (if available) for stable identity
|
||||
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
||||
val peerID = if (serviceData != null && serviceData.size >= 8) {
|
||||
serviceData.joinToString("") { "%02x".format(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (peerID != null) {
|
||||
// Log.v(TAG, "Found peerID $peerID in scan record for $deviceAddress")
|
||||
if (connectionTracker.isPeerConnected(peerID)) {
|
||||
Log.d(TAG, "Deduplication: Peer $peerID is already connected (ignoring $deviceAddress)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}")
|
||||
|
||||
// Store RSSI from scan results for later use (especially for server connections)
|
||||
@@ -332,7 +348,7 @@ class BluetoothGattClientManager(
|
||||
deviceName = device.name,
|
||||
deviceAddress = deviceAddress,
|
||||
rssi = rssi,
|
||||
peerID = null // peerID unknown at scan time
|
||||
peerID = peerID // Use the discovered peerID if available
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
@@ -342,13 +358,12 @@ class BluetoothGattClientManager(
|
||||
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
||||
// Even if we skip connecting, still publish scan result to debug UI
|
||||
try {
|
||||
val pid: String? = null // We don't know peerID until packet exchange
|
||||
DebugSettingsManager.getInstance().addScanResult(
|
||||
DebugScanResult(
|
||||
deviceName = device.name,
|
||||
deviceAddress = deviceAddress,
|
||||
rssi = rssi,
|
||||
peerID = pid
|
||||
peerID = peerID
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
@@ -366,14 +381,19 @@ class BluetoothGattClientManager(
|
||||
return
|
||||
}
|
||||
|
||||
if (connectionTracker.isConnectionLimitReached()) {
|
||||
Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})")
|
||||
// Check if connection limit is reached
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
||||
|
||||
if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) {
|
||||
Log.d(TAG, "Client connection limit reached (overall: $maxOverall, client: $maxClient)")
|
||||
return
|
||||
}
|
||||
|
||||
// Add pending connection and start connection
|
||||
if (connectionTracker.addPendingConnection(deviceAddress)) {
|
||||
connectToDevice(device, rssi)
|
||||
connectToDevice(device, rssi, peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,11 +401,11 @@ class BluetoothGattClientManager(
|
||||
* Connect to a device as GATT client
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int) {
|
||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
|
||||
if (!permissionManager.hasBluetoothPermissions()) return
|
||||
|
||||
val deviceAddress = device.address
|
||||
Log.i(TAG, "Connecting to bitchat device: $deviceAddress")
|
||||
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
|
||||
|
||||
val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
@@ -435,7 +455,8 @@ class BluetoothGattClientManager(
|
||||
device = gatt.device,
|
||||
gatt = gatt,
|
||||
rssi = rssi,
|
||||
isClient = true
|
||||
isClient = true,
|
||||
peerID = peerID // Store the peerID discovered during scan
|
||||
)
|
||||
connectionTracker.addDeviceConnection(deviceAddress, deviceConn)
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ class BluetoothGattServerManager(
|
||||
private val connectionTracker: BluetoothConnectionTracker,
|
||||
private val permissionManager: BluetoothPermissionManager,
|
||||
private val powerManager: PowerManager,
|
||||
private val delegate: BluetoothConnectionManagerDelegate?
|
||||
private val delegate: BluetoothConnectionManagerDelegate?,
|
||||
private val myPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -45,18 +46,15 @@ class BluetoothGattServerManager(
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
// Enforce a server connection limit by canceling the oldest connections (best-effort)
|
||||
fun enforceServerLimit(maxServer: Int) {
|
||||
if (maxServer <= 0) return
|
||||
/**
|
||||
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||
*/
|
||||
fun disconnectDevice(device: BluetoothDevice) {
|
||||
try {
|
||||
val subs = connectionTracker.getSubscribedDevices()
|
||||
if (subs.size > maxServer) {
|
||||
val excess = subs.size - maxServer
|
||||
subs.take(excess).forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
gattServer?.cancelConnection(device)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,9 +120,10 @@ class BluetoothGattServerManager(
|
||||
|
||||
// Try to cancel any active connections explicitly before closing
|
||||
try {
|
||||
val devices = connectionTracker.getSubscribedDevices()
|
||||
devices.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
// Disconnect ALL server connections
|
||||
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||
servers.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,8 @@ import kotlinx.coroutines.channels.actor
|
||||
class BluetoothPacketBroadcaster(
|
||||
private val connectionScope: CoroutineScope,
|
||||
private val connectionTracker: BluetoothConnectionTracker,
|
||||
private val fragmentManager: FragmentManager?
|
||||
private val fragmentManager: FragmentManager?,
|
||||
private val myPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -68,7 +69,9 @@ class BluetoothPacketBroadcaster(
|
||||
incomingAddr: String?,
|
||||
toPeer: String?,
|
||||
toDeviceAddress: String,
|
||||
ttl: UByte
|
||||
ttl: UByte,
|
||||
packetVersion: UByte = 1u,
|
||||
routeInfo: String? = null
|
||||
) {
|
||||
try {
|
||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||
@@ -80,7 +83,9 @@ class BluetoothPacketBroadcaster(
|
||||
toPeerID = toPeer,
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
previousHopPeerID = incomingPeer
|
||||
previousHopPeerID = incomingPeer,
|
||||
packetVersion = packetVersion,
|
||||
routeInfo = routeInfo
|
||||
)
|
||||
// Keep the verbose relay message for human readability
|
||||
manager.logPacketRelayDetailed(
|
||||
@@ -94,7 +99,9 @@ class BluetoothPacketBroadcaster(
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
ttl = ttl,
|
||||
isRelay = true
|
||||
isRelay = true,
|
||||
packetVersion = packetVersion,
|
||||
routeInfo = routeInfo
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore debug logging failures
|
||||
@@ -221,17 +228,19 @@ class BluetoothPacketBroadcaster(
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||
val route = packet.route
|
||||
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
|
||||
|
||||
// Prefer server-side subscriptions
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (serverTarget != null) {
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
@@ -245,7 +254,7 @@ class BluetoothPacketBroadcaster(
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
if (clientTarget != null) {
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ class FragmentManager {
|
||||
private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>()
|
||||
// iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)]
|
||||
private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp
|
||||
|
||||
private val fragmentCumulativeSize = ConcurrentHashMap<String, Int>()
|
||||
|
||||
private val fragmentStateLock = Any()
|
||||
private var globalBufferedBytes: Long = 0L
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: FragmentManagerDelegate? = null
|
||||
|
||||
@@ -77,8 +81,31 @@ class FragmentManager {
|
||||
val fragmentID = FragmentPayload.generateFragmentID()
|
||||
|
||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
|
||||
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
|
||||
// Calculate dynamic fragment size to fit in MTU (512)
|
||||
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
|
||||
val hasRoute = packet.route != null
|
||||
val version = if (hasRoute) 2 else 1
|
||||
val headerSize = if (version == 2) 15 else 13
|
||||
val senderSize = 8
|
||||
val recipientSize = if (packet.recipientID != null) 8 else 0
|
||||
// Route: 1 byte count + 8 bytes per hop
|
||||
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
|
||||
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
|
||||
val endOffset = minOf(offset + maxDataSize, fullData.size)
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
@@ -98,13 +125,16 @@ class FragmentManager {
|
||||
)
|
||||
|
||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||
// Fix: Fragments must inherit source route and use v2 if routed
|
||||
val fragmentPacket = BitchatPacket(
|
||||
version = if (packet.route != null) 2u else 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
ttl = packet.ttl,
|
||||
senderID = packet.senderID,
|
||||
recipientID = packet.recipientID,
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
)
|
||||
|
||||
@@ -146,53 +176,110 @@ class FragmentManager {
|
||||
val fragmentIDString = fragmentPayload.getFragmentIDString()
|
||||
|
||||
Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}")
|
||||
|
||||
// iOS: if incomingFragments[fragmentID] == nil
|
||||
if (!incomingFragments.containsKey(fragmentIDString)) {
|
||||
incomingFragments[fragmentIDString] = mutableMapOf()
|
||||
fragmentMetadata[fragmentIDString] = Triple(
|
||||
fragmentPayload.originalType,
|
||||
fragmentPayload.total,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val maxFragments = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
|
||||
if (fragmentPayload.total > maxFragments) {
|
||||
Log.w(TAG, "Rejecting fragment with excessive total count: ${fragmentPayload.total} > $maxFragments")
|
||||
return null
|
||||
}
|
||||
|
||||
// iOS: incomingFragments[fragmentID]?[index] = Data(fragmentData)
|
||||
incomingFragments[fragmentIDString]?.put(fragmentPayload.index, fragmentPayload.data)
|
||||
|
||||
// iOS: if let fragments = incomingFragments[fragmentID], fragments.count == total
|
||||
val fragmentMap = incomingFragments[fragmentIDString]
|
||||
if (fragmentMap != null && fragmentMap.size == fragmentPayload.total) {
|
||||
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
|
||||
|
||||
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
|
||||
val reassembledData = mutableListOf<Byte>()
|
||||
for (i in 0 until fragmentPayload.total) {
|
||||
fragmentMap[i]?.let { data ->
|
||||
reassembledData.addAll(data.asIterable())
|
||||
|
||||
synchronized(fragmentStateLock) {
|
||||
fragmentMetadata[fragmentIDString]?.let { (expectedType, expectedTotal, _) ->
|
||||
if (expectedTotal != fragmentPayload.total || expectedType != fragmentPayload.originalType) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting fragment for $fragmentIDString: inconsistent metadata " +
|
||||
"(expected type=$expectedType total=$expectedTotal, got type=${fragmentPayload.originalType} total=${fragmentPayload.total})"
|
||||
)
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved - iOS fix
|
||||
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
|
||||
if (originalPacket != null) {
|
||||
// iOS cleanup: incomingFragments.removeValue(forKey: fragmentID)
|
||||
incomingFragments.remove(fragmentIDString)
|
||||
fragmentMetadata.remove(fragmentIDString)
|
||||
|
||||
// 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..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
|
||||
val reassembledData = mutableListOf<Byte>()
|
||||
for (i in 0 until expectedTotal) {
|
||||
fragmentMap[i]?.let { data ->
|
||||
reassembledData.addAll(data.asIterable())
|
||||
}
|
||||
}
|
||||
|
||||
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
|
||||
if (originalPacket != null) {
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
|
||||
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
|
||||
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
|
||||
return suppressedTtlPacket
|
||||
} else {
|
||||
val metadata = fragmentMetadata[fragmentIDString]
|
||||
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
|
||||
}
|
||||
} else {
|
||||
val metadata = fragmentMetadata[fragmentIDString]
|
||||
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
|
||||
val received = fragmentMap.size
|
||||
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/$expectedTotal fragments for $fragmentIDString")
|
||||
}
|
||||
} else {
|
||||
val received = fragmentMap?.size ?: 0
|
||||
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/${fragmentPayload.total} fragments for $fragmentIDString")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Shared mesh coordinator that wires all mesh-layer components and provides common APIs
|
||||
@@ -53,6 +54,7 @@ class MeshCore(
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
|
||||
@@ -65,6 +67,7 @@ class MeshCore(
|
||||
|
||||
init {
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
|
||||
if (sharedGossipManager == null) {
|
||||
@@ -307,6 +310,14 @@ class MeshCore(
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// MeshDelegate intentionally does not expose QR verification yet.
|
||||
}
|
||||
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// MeshDelegate intentionally does not expose QR verification yet.
|
||||
}
|
||||
}
|
||||
|
||||
packetProcessor.delegate = object : PacketProcessorDelegate {
|
||||
@@ -383,6 +394,12 @@ class MeshCore(
|
||||
dispatchGlobal(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
transport.sendPacketToPeer(peerID, routed.packet)
|
||||
TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
val fromPeer = routed.peerID ?: return
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
@@ -604,7 +621,12 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun setDirectConnection(peerID: String, isDirect: Boolean) {
|
||||
peerManager.setDirectConnection(peerID, isDirect)
|
||||
if (isDirect) {
|
||||
directPeers.add(peerID)
|
||||
} else {
|
||||
directPeers.remove(peerID)
|
||||
}
|
||||
peerManager.refreshPeerList()
|
||||
}
|
||||
|
||||
fun updatePeerRSSI(peerID: String, rssi: Int) {
|
||||
|
||||
@@ -15,5 +15,3 @@ interface MeshDelegate {
|
||||
fun getNickname(): String?
|
||||
fun isFavorite(peerID: String): Boolean
|
||||
}
|
||||
|
||||
typealias BluetoothMeshDelegate = MeshDelegate
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
@@ -157,6 +158,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Simplified: Call delegate with messageID and peerID directly
|
||||
delegate?.onReadReceiptReceived(messageID, peerID)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
|
||||
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -278,6 +287,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
previousPeerID = null
|
||||
)
|
||||
|
||||
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||
try {
|
||||
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
||||
return isFirstAnnounce
|
||||
}
|
||||
@@ -385,7 +401,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(),
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
@@ -401,6 +417,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
senderPeerID = peerID,
|
||||
@@ -611,4 +628,6 @@ interface MessageHandlerDelegate {
|
||||
fun onChannelLeave(channel: String, fromPeer: String)
|
||||
fun onDeliveryAckReceived(messageID: String, peerID: String)
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -65,9 +65,40 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
||||
|
||||
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||
val route = relayPacket.route
|
||||
if (!route.isNullOrEmpty()) {
|
||||
// Check for duplicate hops to prevent routing loops
|
||||
if (route.map { it.toHexString() }.toSet().size < route.size) {
|
||||
Log.w(TAG, "Packet with duplicate hops dropped")
|
||||
return
|
||||
}
|
||||
val myIdBytes = hexStringToPeerBytes(myPeerID)
|
||||
val index = route.indexOfFirst { it.contentEquals(myIdBytes) }
|
||||
if (index >= 0) {
|
||||
val nextHopIdHex: String? = run {
|
||||
val nextIndex = index + 1
|
||||
if (nextIndex < route.size) {
|
||||
route[nextIndex].toHexString()
|
||||
} else {
|
||||
// We are the last intermediate; try final recipient as next hop
|
||||
relayPacket.recipientID?.toHexString()
|
||||
}
|
||||
}
|
||||
if (nextHopIdHex != null) {
|
||||
val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
|
||||
if (success) {
|
||||
Log.i(TAG, "📦 Source-route relay: ${peerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
|
||||
return
|
||||
} else {
|
||||
Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply relay logic based on packet type and debug switch
|
||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||
|
||||
if (shouldRelay) {
|
||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||
} else {
|
||||
@@ -170,4 +201,17 @@ interface PacketRelayManagerDelegate {
|
||||
|
||||
// Packet operations
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
}
|
||||
|
||||
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < hex.length && out < 8) {
|
||||
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ class PeerManager {
|
||||
// Delegate for callbacks
|
||||
var delegate: PeerManagerDelegate? = null
|
||||
|
||||
// Callback to check if a peer is directly connected (injected by BluetoothMeshService)
|
||||
var isPeerDirectlyConnected: ((String) -> Boolean)? = null
|
||||
|
||||
// Coroutines
|
||||
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -165,10 +168,18 @@ class PeerManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peer info
|
||||
* Get peer info with dynamic direct connection status
|
||||
*/
|
||||
fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
return peers[peerID]
|
||||
return peers[peerID]?.let { info ->
|
||||
// Dynamically check direct connection status from ConnectionManager
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
|
||||
if (info.isDirectConnection != isDirect) {
|
||||
info.copy(isDirectConnection = isDirect)
|
||||
} else {
|
||||
info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,27 +190,12 @@ class PeerManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all verified peers
|
||||
* Get all verified peers with dynamic direct connection status
|
||||
*/
|
||||
fun getVerifiedPeers(): Map<String, PeerInfo> {
|
||||
return peers.filterValues { it.isVerifiedNickname }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether a peer is directly connected over Bluetooth.
|
||||
* Triggers a peer list update to refresh UI badges.
|
||||
*/
|
||||
fun setDirectConnection(peerID: String, isDirect: Boolean) {
|
||||
peers[peerID]?.let { existing ->
|
||||
if (existing.isDirectConnection != isDirect) {
|
||||
peers[peerID] = existing.copy(isDirectConnection = isDirect)
|
||||
notifyPeerListUpdate()
|
||||
// NEW: notify UI state (if available via delegate path) about directness change
|
||||
try {
|
||||
// Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList
|
||||
// No direct reference to UI layer here by design.
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) ->
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false
|
||||
if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +315,7 @@ class PeerManager {
|
||||
*/
|
||||
fun isPeerActive(peerID: String): Boolean {
|
||||
val info = peers[peerID] ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
|
||||
return info.isConnected
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,8 +343,7 @@ class PeerManager {
|
||||
* Get list of active peer IDs
|
||||
*/
|
||||
fun getActivePeerIDs(): List<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
|
||||
return peers.filterValues { it.isConnected }
|
||||
.keys
|
||||
.toList()
|
||||
.sorted()
|
||||
@@ -386,7 +380,11 @@ class PeerManager {
|
||||
return buildString {
|
||||
appendLine("=== Peer Manager Debug Info ===")
|
||||
appendLine("Active Peers: ${activeIds.size}")
|
||||
peers.forEach { (peerID, info) ->
|
||||
peers.forEach { (peerID, storedInfo) ->
|
||||
// Use dynamic direct status for debug log accuracy
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
|
||||
val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo
|
||||
|
||||
val timeSince = (now - info.lastSeen) / 1000
|
||||
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
|
||||
val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key
|
||||
|
||||
@@ -7,7 +7,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -15,7 +21,7 @@ import kotlin.math.max
|
||||
* Power-aware Bluetooth management for bitchat
|
||||
* Adjusts scanning, advertising, and connection behavior based on battery state
|
||||
*/
|
||||
class PowerManager(private val context: Context) {
|
||||
class PowerManager(private val context: Context) : LifecycleEventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PowerManager"
|
||||
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
|
||||
private var currentMode = PowerMode.BALANCED
|
||||
private var isCharging = false
|
||||
private var batteryLevel = 100
|
||||
private var isAppInBackground = false
|
||||
private var isAppInBackground = true
|
||||
|
||||
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var dutyCycleJob: Job? = null
|
||||
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
|
||||
|
||||
init {
|
||||
registerBatteryReceiver()
|
||||
|
||||
// Register for process lifecycle events on the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerMode()
|
||||
}
|
||||
|
||||
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
|
||||
Log.i(TAG, "Stopping power management")
|
||||
powerScope.cancel()
|
||||
unregisterBatteryReceiver()
|
||||
|
||||
// Unregister lifecycle observer
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
if (isAppInBackground != inBackground) {
|
||||
isAppInBackground = inBackground
|
||||
Log.d(TAG, "App background state changed: $inBackground")
|
||||
updatePowerMode()
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
|
||||
isAppInBackground = false
|
||||
updatePowerMode()
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
|
||||
isAppInBackground = true
|
||||
updatePowerMode()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
|
||||
PowerMode.ULTRA_LOW_POWER -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
}
|
||||
|
||||
@@ -56,21 +56,30 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
// Duplicate detection
|
||||
val messageID = generateMessageID(packet, peerID)
|
||||
if (messageType != MessageType.ANNOUNCE) {
|
||||
if (processedMessages.contains(messageID)) {
|
||||
|
||||
if (processedMessages.contains(messageID)) {
|
||||
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
|
||||
// This ensures we catch the "first announce" on a new connection for binding,
|
||||
// while still dropping looped/relayed duplicates.
|
||||
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
|
||||
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (!isFreshAnnounce) {
|
||||
Log.d(TAG, "Dropping duplicate packet: $messageID")
|
||||
return false
|
||||
}
|
||||
// Add to processed messages
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
} else {
|
||||
// Do not deduplicate ANNOUNCE at the security layer.
|
||||
// They are signed/idempotent and we need to ensure first-announce per-connection can bind.
|
||||
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
|
||||
@@ -29,15 +29,15 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Static identity key (persistent across app restarts) - loaded from secure storage
|
||||
private val staticIdentityPrivateKey: ByteArray
|
||||
private val staticIdentityPublicKey: ByteArray
|
||||
private var staticIdentityPrivateKey: ByteArray
|
||||
private var staticIdentityPublicKey: ByteArray
|
||||
|
||||
// Ed25519 signing key (persistent across app restarts) - loaded from secure storage
|
||||
private val signingPrivateKey: ByteArray
|
||||
private val signingPublicKey: ByteArray
|
||||
private var signingPrivateKey: ByteArray
|
||||
private var signingPublicKey: ByteArray
|
||||
|
||||
// Session management
|
||||
private val sessionManager: NoiseSessionManager
|
||||
private lateinit var sessionManager: NoiseSessionManager
|
||||
|
||||
// Channel encryption for password-protected channels
|
||||
private val channelEncryption = NoiseChannelEncryption()
|
||||
@@ -56,6 +56,33 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// Initialize identity state manager for persistent storage
|
||||
identityStateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Load or create keys - temporary placeholders
|
||||
staticIdentityPrivateKey = ByteArray(32)
|
||||
staticIdentityPublicKey = ByteArray(32)
|
||||
signingPrivateKey = ByteArray(32)
|
||||
signingPublicKey = ByteArray(32)
|
||||
|
||||
loadOrGenerateKeys()
|
||||
|
||||
// Initialize session manager
|
||||
initializeSessionManager()
|
||||
}
|
||||
|
||||
private fun initializeSessionManager() {
|
||||
// Create new session manager with current keys
|
||||
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
|
||||
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||
handleSessionEstablished(peerID, remoteStaticKey)
|
||||
}
|
||||
|
||||
// Ensure any other callbacks are wired if needed
|
||||
// sessionManager.onSessionFailed could be wired if we exposed it
|
||||
}
|
||||
|
||||
private fun loadOrGenerateKeys() {
|
||||
// Load or create static identity key (persistent across sessions)
|
||||
val loadedKeyPair = identityStateManager.loadStaticKey()
|
||||
if (loadedKeyPair != null) {
|
||||
@@ -89,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
|
||||
}
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* GeohashAliasRegistry
|
||||
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
|
||||
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
|
||||
* - Persisted to SharedPreferences to survive app restarts.
|
||||
*/
|
||||
object GeohashAliasRegistry {
|
||||
private val map: MutableMap<String, String> = ConcurrentHashMap()
|
||||
private const val PREFS_NAME = "geohash_alias_registry"
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs == null) {
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
loadFromPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromPrefs() {
|
||||
prefs?.let { p ->
|
||||
val allEntries = p.all
|
||||
for ((key, value) in allEntries) {
|
||||
if (key is String && value is String) {
|
||||
map[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun put(alias: String, pubkeyHex: String) {
|
||||
map[alias] = pubkeyHex
|
||||
prefs?.edit()?.putString(alias, pubkeyHex)?.apply()
|
||||
}
|
||||
|
||||
fun get(alias: String): String? = map[alias]
|
||||
@@ -20,5 +43,8 @@ object GeohashAliasRegistry {
|
||||
|
||||
fun snapshot(): Map<String, String> = HashMap(map)
|
||||
|
||||
fun clear() { map.clear() }
|
||||
fun clear() {
|
||||
map.clear()
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* GeohashConversationRegistry
|
||||
* - Global, thread-safe registry of conversationKey (e.g., "nostr_<pub16>") -> source geohash
|
||||
* - Enables routing geohash DMs from anywhere by providing the correct geohash identity
|
||||
* - Persisted to SharedPreferences to survive app restarts.
|
||||
*/
|
||||
object GeohashConversationRegistry {
|
||||
private val map = ConcurrentHashMap<String, String>()
|
||||
private const val PREFS_NAME = "geohash_conversation_registry"
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs == null) {
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
loadFromPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromPrefs() {
|
||||
prefs?.let { p ->
|
||||
val allEntries = p.all
|
||||
for ((key, value) in allEntries) {
|
||||
if (key is String && value is String) {
|
||||
map[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun set(convKey: String, geohash: String) {
|
||||
if (geohash.isNotEmpty()) map[convKey] = geohash
|
||||
if (geohash.isNotEmpty()) {
|
||||
map[convKey] = geohash
|
||||
prefs?.edit()?.putString(convKey, geohash)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun get(convKey: String): String? = map[convKey]
|
||||
|
||||
fun snapshot(): Map<String, String> = map.toMap()
|
||||
|
||||
fun clear() = map.clear()
|
||||
fun clear() {
|
||||
map.clear()
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,17 @@ class GeohashMessageHandler(
|
||||
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
if (event.kind != 20000) return@launch
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
|
||||
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||
if (dedupe(event.id)) return@launch
|
||||
|
||||
// PoW validation (if enabled)
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
// PoW validation (if enabled) - apply to chat messages primarily
|
||||
if (event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked users check (use injected DataManager which has loaded state)
|
||||
@@ -62,7 +64,12 @@ class GeohashMessageHandler(
|
||||
|
||||
// Update repository (participants, nickname, teleport)
|
||||
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||
|
||||
// Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events
|
||||
if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||
}
|
||||
|
||||
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||
@@ -70,6 +77,9 @@ class GeohashMessageHandler(
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Stop here for presence events - they don't produce chat messages
|
||||
if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch
|
||||
|
||||
// Skip our own events for message emission
|
||||
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||
|
||||
@@ -43,6 +43,20 @@ class GeohashRepository(
|
||||
}?.key
|
||||
}
|
||||
|
||||
fun findPubkeyByShortId(shortId: String): String? {
|
||||
// First check cached nicknames (fastest)
|
||||
var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
|
||||
if (found != null) return found
|
||||
|
||||
// If not found in nicknames (e.g. anon user), check all known participants across all geohashes
|
||||
for (participants in geohashParticipants.values) {
|
||||
found = participants.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
|
||||
if (found != null) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
|
||||
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.SeenMessageStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: com.bitchat.android.model.NoisePayload,
|
||||
payload: NoisePayload,
|
||||
convKey: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
|
||||
recipientIdentity: NostrIdentity
|
||||
) {
|
||||
when (payload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
|
||||
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
|
||||
seenStore.markRead(pm.messageID)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
||||
NoisePayloadType.FILE_TRANSFER -> {
|
||||
// Properly handle encrypted file transfer
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
|
||||
val file = BitchatFilePacket.decode(payload.data)
|
||||
if (file != null) {
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
|
||||
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
|
||||
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
|
||||
}
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ object NostrKind {
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,11 +32,11 @@ data class NostrFilter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000)
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000 and 20001)
|
||||
*/
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("g" to listOf(geohash)),
|
||||
limit = limit
|
||||
|
||||
@@ -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<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.GEOHASH_PRESENCE,
|
||||
tags = tags,
|
||||
content = ""
|
||||
)
|
||||
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
|
||||
+251
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Preference manager for background location skip choice.
|
||||
*/
|
||||
object BackgroundLocationPreferenceManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
|
||||
|
||||
fun setSkipped(context: Context, skipped: Boolean) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply()
|
||||
}
|
||||
|
||||
fun isSkipped(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
|
||||
private val activity: ComponentActivity,
|
||||
private val permissionManager: PermissionManager,
|
||||
private val onOnboardingComplete: () -> Unit,
|
||||
private val onBackgroundLocationRequired: () -> Unit,
|
||||
private val onOnboardingFailed: (String) -> Unit
|
||||
) {
|
||||
|
||||
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
|
||||
}
|
||||
|
||||
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
|
||||
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
|
||||
|
||||
init {
|
||||
setupPermissionLauncher()
|
||||
setupBackgroundLocationLauncher()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBackgroundLocationLauncher() {
|
||||
backgroundLocationLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
handleBackgroundLocationResult(granted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the onboarding process
|
||||
*/
|
||||
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
|
||||
Log.d(TAG, "Starting onboarding process")
|
||||
permissionManager.logPermissionStatus()
|
||||
|
||||
if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d(TAG, "All permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; background location recommended")
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
Log.d(TAG, "Required permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Missing permissions, need to start explanation flow")
|
||||
// The explanation screen will be shown by the calling activity
|
||||
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
|
||||
val missingPermissions = (missingRequired + optionalToRequest).distinct()
|
||||
|
||||
if (missingPermissions.isEmpty()) {
|
||||
completeOnboarding()
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
completeOnboarding()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
|
||||
val criticalGranted = criticalPermissions.all { permissions[it] == true }
|
||||
|
||||
when {
|
||||
allGranted -> {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
}
|
||||
criticalGranted -> {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; requesting background location next")
|
||||
onBackgroundLocationRequired()
|
||||
return
|
||||
}
|
||||
if (allGranted) {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
} else {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Critical permissions denied")
|
||||
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestBackgroundLocation() {
|
||||
val permission = permissionManager.getBackgroundLocationPermission()
|
||||
if (permission == null) {
|
||||
completeOnboarding()
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Requesting background location permission")
|
||||
backgroundLocationLauncher?.launch(permission)
|
||||
}
|
||||
|
||||
private fun handleBackgroundLocationResult(granted: Boolean) {
|
||||
if (granted) {
|
||||
Log.d(TAG, "Background location permission granted")
|
||||
} else {
|
||||
Log.w(TAG, "Background location permission denied; continuing without it")
|
||||
}
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun skipBackgroundLocation() {
|
||||
Log.d(TAG, "User skipped background location permission")
|
||||
BackgroundLocationPreferenceManager.setSkipped(activity, true)
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun checkBackgroundLocationAndProceed() {
|
||||
if (!shouldRequestBackgroundLocation()) {
|
||||
completeOnboarding()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRequestBackgroundLocation(): Boolean {
|
||||
return permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!BackgroundLocationPreferenceManager.isSkipped(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of critical permissions that are absolutely required
|
||||
*/
|
||||
private fun getCriticalPermissions(): List<String> {
|
||||
// For bitchat, Bluetooth and location permissions are critical
|
||||
// Notifications are nice-to-have but not critical
|
||||
return permissionManager.getRequiredPermissions().filter { permission ->
|
||||
!permission.contains("POST_NOTIFICATIONS")
|
||||
}
|
||||
// Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
|
||||
return permissionManager.getRequiredPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
|
||||
private fun getPermissionDisplayName(permission: String): String {
|
||||
return when {
|
||||
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
|
||||
permission.contains("BACKGROUND") -> "Background Location"
|
||||
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
||||
permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)"
|
||||
permission.contains("NOTIFICATION") -> "Notifications"
|
||||
|
||||
@@ -6,8 +6,9 @@ enum class OnboardingState {
|
||||
LOCATION_CHECK,
|
||||
BATTERY_OPTIMIZATION_CHECK,
|
||||
PERMISSION_EXPLANATION,
|
||||
BACKGROUND_LOCATION_EXPLANATION,
|
||||
PERMISSION_REQUESTING,
|
||||
INITIALIZING,
|
||||
COMPLETE,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,10 @@ import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -210,29 +208,6 @@ private fun PermissionCategoryCard(
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
if (category.type == PermissionType.PRECISE_LOCATION) {
|
||||
// Extra emphasis for location permission
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.cd_warning),
|
||||
tint = Color(0xFFFF9800),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.location_tracking_warning),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFFF9800)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +216,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
||||
return when (permissionType) {
|
||||
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
|
||||
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Centralized permission management for bitchat app
|
||||
@@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions required by the app
|
||||
* Get required permissions that can be requested together.
|
||||
* Background location is handled separately to ensure correct request order.
|
||||
* Note: Notification permission is optional and not included here,
|
||||
* so the app works without notification access.
|
||||
*/
|
||||
@@ -77,6 +79,27 @@ class PermissionManager(private val context: Context) {
|
||||
return permissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Background location permission is required on Android 10+ for background BLE scanning.
|
||||
* Must be requested after foreground location permissions are granted.
|
||||
*/
|
||||
fun needsBackgroundLocationPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
}
|
||||
|
||||
fun getBackgroundLocationPermission(): String? {
|
||||
return if (needsBackgroundLocationPermission()) {
|
||||
Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isBackgroundLocationGranted(): Boolean {
|
||||
val permission = getBackgroundLocationPermission() ?: return true
|
||||
return isPermissionGranted(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optional permissions that improve the experience but aren't required.
|
||||
* Currently includes POST_NOTIFICATIONS on Android 13+.
|
||||
@@ -98,9 +121,13 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required permissions are granted
|
||||
* Check if all required permissions are granted (background location is optional).
|
||||
*/
|
||||
fun areAllPermissionsGranted(): Boolean {
|
||||
return areRequiredPermissionsGranted()
|
||||
}
|
||||
|
||||
fun areRequiredPermissionsGranted(): Boolean {
|
||||
return getRequiredPermissions().all { isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
@@ -136,6 +163,11 @@ class PermissionManager(private val context: Context) {
|
||||
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
fun getMissingBackgroundLocationPermission(): List<String> {
|
||||
val permission = getBackgroundLocationPermission() ?: return emptyList()
|
||||
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categorized permission information for display
|
||||
*/
|
||||
@@ -196,6 +228,19 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
if (needsBackgroundLocationPermission()) {
|
||||
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.BACKGROUND_LOCATION,
|
||||
description = context.getString(R.string.perm_background_location_desc),
|
||||
permissions = backgroundPermission,
|
||||
isGranted = backgroundPermission.all { isPermissionGranted(it) },
|
||||
systemDescription = context.getString(R.string.perm_background_location_system)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications category (if applicable)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
categories.add(
|
||||
@@ -235,7 +280,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine("Permission Diagnostics:")
|
||||
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
|
||||
appendLine("First time launch: ${isFirstTimeLaunch()}")
|
||||
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine()
|
||||
|
||||
getCategorizedPermissions().forEach { category ->
|
||||
@@ -247,7 +292,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine()
|
||||
}
|
||||
|
||||
val missing = getMissingPermissions()
|
||||
val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
|
||||
if (missing.isNotEmpty()) {
|
||||
appendLine("Missing permissions:")
|
||||
missing.forEach { permission ->
|
||||
@@ -279,6 +324,7 @@ data class PermissionCategory(
|
||||
enum class PermissionType(val nameValue: String) {
|
||||
NEARBY_DEVICES("Nearby Devices"),
|
||||
PRECISE_LOCATION("Precise Location"),
|
||||
BACKGROUND_LOCATION("Background Location"),
|
||||
MICROPHONE("Microphone"),
|
||||
NOTIFICATIONS("Notifications"),
|
||||
WIFI_AWARE("Wi‑Fi Aware"),
|
||||
|
||||
@@ -36,7 +36,7 @@ object SpecialRecipients {
|
||||
/**
|
||||
* Binary packet format - 100% backward compatible with iOS version
|
||||
*
|
||||
* Header (13 bytes for v1, 15 bytes for v2):
|
||||
* Header (14 bytes for v1, 16 bytes for v2):
|
||||
* - Version: 1 byte
|
||||
* - Type: 1 byte
|
||||
* - TTL: 1 byte
|
||||
@@ -59,7 +59,8 @@ data class BitchatPacket(
|
||||
val timestamp: ULong,
|
||||
val payload: ByteArray,
|
||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||
var ttl: UByte
|
||||
var ttl: UByte,
|
||||
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||
) : Parcelable {
|
||||
|
||||
constructor(
|
||||
@@ -97,6 +98,7 @@ data class BitchatPacket(
|
||||
timestamp = timestamp,
|
||||
payload = payload,
|
||||
signature = null, // Remove signature for signing
|
||||
route = route,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
@@ -149,6 +151,11 @@ data class BitchatPacket(
|
||||
if (!signature.contentEquals(other.signature)) return false
|
||||
} else if (other.signature != null) return false
|
||||
if (ttl != other.ttl) return false
|
||||
if (route != null || other.route != null) {
|
||||
val a = route?.map { it.toList() } ?: emptyList()
|
||||
val b = other.route?.map { it.toList() } ?: emptyList()
|
||||
if (a != b) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -162,6 +169,7 @@ data class BitchatPacket(
|
||||
result = 31 * result + payload.contentHashCode()
|
||||
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
||||
result = 31 * result + ttl.hashCode()
|
||||
result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -170,8 +178,8 @@ data class BitchatPacket(
|
||||
* Binary Protocol implementation - supports v1 and v2, backward compatible
|
||||
*/
|
||||
object BinaryProtocol {
|
||||
private const val HEADER_SIZE_V1 = 13
|
||||
private const val HEADER_SIZE_V2 = 15
|
||||
private const val HEADER_SIZE_V1 = 14
|
||||
private const val HEADER_SIZE_V2 = 16
|
||||
private const val SENDER_ID_SIZE = 8
|
||||
private const val RECIPIENT_ID_SIZE = 8
|
||||
private const val SIGNATURE_SIZE = 64
|
||||
@@ -180,6 +188,7 @@ object BinaryProtocol {
|
||||
const val HAS_RECIPIENT: UByte = 0x01u
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
const val IS_COMPRESSED: UByte = 0x04u
|
||||
const val HAS_ROUTE: UByte = 0x08u
|
||||
}
|
||||
|
||||
private fun getHeaderSize(version: UByte): Int {
|
||||
@@ -193,12 +202,12 @@ object BinaryProtocol {
|
||||
try {
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UShort? = null
|
||||
var originalPayloadSize: Int? = null
|
||||
var isCompressed = false
|
||||
|
||||
if (CompressionUtil.shouldCompress(payload)) {
|
||||
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
||||
originalPayloadSize = payload.size.toUShort()
|
||||
originalPayloadSize = payload.size
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
}
|
||||
@@ -208,8 +217,12 @@ object BinaryProtocol {
|
||||
val headerSize = getHeaderSize(packet.version)
|
||||
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
|
||||
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
||||
val payloadBytes = payload.size + if (isCompressed) 2 else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
|
||||
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
|
||||
val payloadBytes = payload.size + sizeFieldBytes
|
||||
val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE)
|
||||
} else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack
|
||||
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
|
||||
// Header
|
||||
@@ -231,10 +244,14 @@ object BinaryProtocol {
|
||||
if (isCompressed) {
|
||||
flags = flags or Flags.IS_COMPRESSED
|
||||
}
|
||||
// HAS_ROUTE is only supported for v2+ packets
|
||||
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
flags = flags or Flags.HAS_ROUTE
|
||||
}
|
||||
buffer.put(flags.toByte())
|
||||
|
||||
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
|
||||
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
|
||||
val payloadDataSize = payload.size + sizeFieldBytes
|
||||
if (packet.version >= 2u.toUByte()) {
|
||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||
} else {
|
||||
@@ -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<ByteArray>? = if (hasRoute) {
|
||||
val count = buffer.get().toUByte().toInt()
|
||||
if (count == 0) {
|
||||
null // Treat empty route list as null to enforce canonical representation
|
||||
} else {
|
||||
val hops = mutableListOf<ByteArray>()
|
||||
repeat(count) {
|
||||
val hop = ByteArray(SENDER_ID_SIZE)
|
||||
buffer.get(hop)
|
||||
hops.add(hop)
|
||||
}
|
||||
hops
|
||||
}
|
||||
} else null
|
||||
|
||||
// Payload
|
||||
val payload = if (isCompressed) {
|
||||
// First 2 bytes are original size
|
||||
if (payloadLength.toInt() < 2) return null
|
||||
val originalSize = buffer.getShort().toInt()
|
||||
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||
if (payloadLength.toInt() < lengthFieldBytes) return null
|
||||
|
||||
val originalSize = if (version >= 2u.toUByte()) {
|
||||
buffer.getInt()
|
||||
} else {
|
||||
buffer.getShort().toUShort().toInt()
|
||||
}
|
||||
|
||||
// Compressed payload
|
||||
val compressedPayload = ByteArray(payloadLength.toInt() - 2)
|
||||
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
|
||||
// Security check: Compression bomb protection
|
||||
if (compressedSize > 0) {
|
||||
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
||||
if (ratio > 50_000.0) {
|
||||
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress
|
||||
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
||||
@@ -383,10 +467,11 @@ object BinaryProtocol {
|
||||
timestamp = timestamp,
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
ttl = ttl
|
||||
ttl = ttl,
|
||||
route = route
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.MainActivity
|
||||
@@ -111,7 +113,8 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private var updateJob: Job? = null
|
||||
private var meshService: BluetoothMeshService? = null
|
||||
private val meshService: BluetoothMeshService?
|
||||
get() = MeshServiceHolder.meshService
|
||||
private val serviceJob = Job()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||
private var isInForeground: Boolean = false
|
||||
@@ -122,15 +125,15 @@ class MeshForegroundService : Service() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
createChannel()
|
||||
|
||||
// Adopt or create the mesh service
|
||||
// Ensure mesh service exists in holder (create if needed)
|
||||
val existing = MeshServiceHolder.meshService
|
||||
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
|
||||
if (existing != null) {
|
||||
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
|
||||
Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder")
|
||||
} else {
|
||||
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
|
||||
val created = MeshServiceHolder.getOrCreate(applicationContext)
|
||||
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
|
||||
MeshServiceHolder.attach(created)
|
||||
}
|
||||
MeshServiceHolder.attach(meshService!!)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -176,7 +179,7 @@ class MeshForegroundService : Service() {
|
||||
// If we became eligible and are not in foreground yet, promote once
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
||||
startForeground(NOTIFICATION_ID, n)
|
||||
startForegroundCompat(n)
|
||||
isInForeground = true
|
||||
} else {
|
||||
updateNotification(force = true)
|
||||
@@ -191,7 +194,7 @@ class MeshForegroundService : Service() {
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
startForegroundCompat(notification)
|
||||
isInForeground = true
|
||||
}
|
||||
|
||||
@@ -226,7 +229,8 @@ class MeshForegroundService : Service() {
|
||||
if (!hasBluetoothPermissions()) return
|
||||
try {
|
||||
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
|
||||
meshService?.startServices()
|
||||
val service = MeshServiceHolder.getOrCreate(applicationContext)
|
||||
service.startServices()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
|
||||
}
|
||||
@@ -326,6 +330,35 @@ class MeshForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
return fine || coarse
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val type = if (hasLocationPermission()) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
}
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, notification, type)
|
||||
} catch (e: SecurityException) {
|
||||
// Fallback for cases where "While In Use" permission exists but background start is restricted
|
||||
if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
|
||||
android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
updateJob?.cancel()
|
||||
updateJob = null
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@@ -35,8 +36,10 @@ object AppStateStore {
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicKey)
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
}
|
||||
}
|
||||
@@ -100,10 +103,22 @@ object AppStateStore {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
_peers.value = emptyList()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_channelMessages.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publicMessageKey(msg: BitchatMessage): String {
|
||||
val sender = msg.senderPeerID ?: msg.sender
|
||||
return listOf(
|
||||
sender,
|
||||
msg.timestamp.time.toString(),
|
||||
msg.type.name,
|
||||
msg.channel ?: "",
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ object ConversationAliasResolver {
|
||||
if (selected != null && keysToMerge.contains(selected)) {
|
||||
state.setSelectedPrivateChatPeer(targetPeerID)
|
||||
}
|
||||
|
||||
// Switch sheet peer if currently viewing an alias that got merged
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.bitchat.android.nostr.NostrTransport
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private val mesh: BluetoothMeshService,
|
||||
private var mesh: BluetoothMeshService,
|
||||
private val nostr: NostrTransport
|
||||
) {
|
||||
companion object {
|
||||
@@ -19,22 +19,22 @@ class MessageRouter private constructor(
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
INSTANCE?.also {
|
||||
// Update mesh reference if needed and keep senderPeerID in sync
|
||||
it.nostr.senderPeerID = mesh.myPeerID
|
||||
return it
|
||||
}
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always update mesh reference and sync peer ID
|
||||
instance.mesh = mesh
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
read.clear()
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun trim(set: LinkedHashSet<String>) {
|
||||
if (set.size <= MAX_IDS) return
|
||||
val it = set.iterator()
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
import androidx.core.net.toUri
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* QR verification helpers: schema, signing, and basic challenge/response helpers.
|
||||
*/
|
||||
object VerificationService {
|
||||
private const val CONTEXT = "bitchat-verify-v1"
|
||||
private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
|
||||
|
||||
private var encryptionServiceRef: WeakReference<EncryptionService>? = null
|
||||
|
||||
fun configure(encryptionService: EncryptionService) {
|
||||
this.encryptionServiceRef = WeakReference(encryptionService)
|
||||
}
|
||||
|
||||
data class VerificationQR(
|
||||
val v: Int,
|
||||
val noiseKeyHex: String,
|
||||
val signKeyHex: String,
|
||||
val npub: String?,
|
||||
val nickname: String,
|
||||
val ts: Long,
|
||||
val nonceB64: String,
|
||||
val sigHex: String
|
||||
) {
|
||||
fun canonicalBytes(): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
fun appendField(value: String) {
|
||||
val data = value.toByteArray(Charsets.UTF_8)
|
||||
val len = minOf(data.size, 255)
|
||||
out.write(len)
|
||||
out.write(data, 0, len)
|
||||
}
|
||||
|
||||
appendField(CONTEXT)
|
||||
appendField(v.toString())
|
||||
appendField(noiseKeyHex.lowercase())
|
||||
appendField(signKeyHex.lowercase())
|
||||
appendField(npub ?: "")
|
||||
appendField(nickname)
|
||||
appendField(ts.toString())
|
||||
appendField(nonceB64)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun toUrlString(): String {
|
||||
val builder = Uri.Builder()
|
||||
.scheme("bitchat")
|
||||
.authority("verify")
|
||||
.appendQueryParameter("v", v.toString())
|
||||
.appendQueryParameter("noise", noiseKeyHex)
|
||||
.appendQueryParameter("sign", signKeyHex)
|
||||
.appendQueryParameter("nick", nickname)
|
||||
.appendQueryParameter("ts", ts.toString())
|
||||
.appendQueryParameter("nonce", nonceB64)
|
||||
.appendQueryParameter("sig", sigHex)
|
||||
if (npub != null) {
|
||||
builder.appendQueryParameter("npub", npub)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromUrlString(urlString: String): VerificationQR? {
|
||||
val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return null
|
||||
|
||||
val vStr = uri.getQueryParameter("v") ?: return null
|
||||
val v = vStr.toIntOrNull() ?: return null
|
||||
val noise = uri.getQueryParameter("noise") ?: return null
|
||||
val sign = uri.getQueryParameter("sign") ?: return null
|
||||
val nick = uri.getQueryParameter("nick") ?: return null
|
||||
val tsStr = uri.getQueryParameter("ts") ?: return null
|
||||
val ts = tsStr.toLongOrNull() ?: return null
|
||||
val nonce = uri.getQueryParameter("nonce") ?: return null
|
||||
val sig = uri.getQueryParameter("sig") ?: return null
|
||||
val npub = uri.getQueryParameter("npub")
|
||||
|
||||
return VerificationQR(
|
||||
v = v,
|
||||
noiseKeyHex = noise,
|
||||
signKeyHex = sign,
|
||||
npub = npub,
|
||||
nickname = nick,
|
||||
ts = ts,
|
||||
nonceB64 = nonce,
|
||||
sigHex = sig
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val cache = Cache.last
|
||||
if (cache != null && cache.nickname == nickname && cache.npub == npub) {
|
||||
if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
|
||||
return cache.value
|
||||
}
|
||||
}
|
||||
|
||||
val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
|
||||
val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
|
||||
val ts = System.currentTimeMillis() / 1000L
|
||||
val nonce = ByteArray(16)
|
||||
SecureRandom().nextBytes(nonce)
|
||||
val nonceB64 = Base64.encodeToString(
|
||||
nonce,
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
|
||||
val payload = VerificationQR(
|
||||
v = 1,
|
||||
noiseKeyHex = noiseKey,
|
||||
signKeyHex = signKey,
|
||||
npub = npub,
|
||||
nickname = nickname,
|
||||
ts = ts,
|
||||
nonceB64 = nonceB64,
|
||||
sigHex = ""
|
||||
)
|
||||
|
||||
val signature = service.signData(payload.canonicalBytes()) ?: return null
|
||||
val signed = payload.copy(sigHex = signature.hexEncodedString())
|
||||
val out = signed.toUrlString()
|
||||
Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
fun verifyScannedQR(
|
||||
urlString: String,
|
||||
maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
|
||||
): VerificationQR? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val qr = VerificationQR.fromUrlString(urlString) ?: return null
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
if (now - qr.ts > maxAgeSeconds) return null
|
||||
|
||||
val sig = qr.sigHex.dataFromHexString() ?: return null
|
||||
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
|
||||
val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
|
||||
return if (ok) qr else null
|
||||
}
|
||||
|
||||
fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val sig = service.signData(msg.toByteArray()) ?: return null
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
out.write(0x03)
|
||||
out.write(minOf(sig.size, 255))
|
||||
out.write(sig, 0, minOf(sig.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun parseVerifyChallenge(data: ByteArray): Pair<String, ByteArray>? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
return noise to nonce
|
||||
}
|
||||
|
||||
data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as VerifyResponse
|
||||
|
||||
if (noiseKeyHex != other.noiseKeyHex) return false
|
||||
if (!nonceA.contentEquals(other.nonceA)) return false
|
||||
if (!signature.contentEquals(other.signature)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = noiseKeyHex.hashCode()
|
||||
result = 31 * result + nonceA.contentHashCode()
|
||||
result = 31 * result + signature.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
val t3 = take(1) ?: return null
|
||||
if (t3[0].toInt() != 0x03) return null
|
||||
val l3 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val sig = take(l3) ?: return null
|
||||
|
||||
return VerifyResponse(noise, nonce, sig)
|
||||
}
|
||||
|
||||
fun verifyResponseSignature(
|
||||
noiseKeyHex: String,
|
||||
nonceA: ByteArray,
|
||||
signature: ByteArray,
|
||||
signerPublicKeyHex: String
|
||||
): Boolean {
|
||||
val service = encryptionServiceRef?.get() ?: return false
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
|
||||
return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
|
||||
}
|
||||
|
||||
private data class CacheEntry(
|
||||
val nickname: String,
|
||||
val npub: String?,
|
||||
val builtAtMs: Long,
|
||||
val value: String
|
||||
)
|
||||
|
||||
private object Cache {
|
||||
var last: CacheEntry? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
|
||||
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
|
||||
*/
|
||||
object GossipTLV {
|
||||
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
|
||||
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
|
||||
|
||||
/**
|
||||
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
|
||||
*/
|
||||
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
|
||||
val unique = peerIDs.distinct().take(10)
|
||||
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
|
||||
if (valueBytes.size > 255) {
|
||||
// Safety check, though 10*8 = 80 bytes, so well under 255
|
||||
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
|
||||
}
|
||||
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a TLV-encoded announce payload and extract neighbor peerIDs.
|
||||
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
|
||||
*/
|
||||
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
|
||||
val result = mutableListOf<String>()
|
||||
var offset = 0
|
||||
while (offset + 2 <= payload.size) {
|
||||
val type = payload[offset].toUByte()
|
||||
val len = payload[offset + 1].toUByte().toInt()
|
||||
offset += 2
|
||||
if (offset + len > payload.size) break
|
||||
val value = payload.sliceArray(offset until offset + len)
|
||||
offset += len
|
||||
|
||||
if (type == DIRECT_NEIGHBORS_TYPE) {
|
||||
// Value is N*8 bytes of peer IDs
|
||||
var pos = 0
|
||||
while (pos + 8 <= value.size) {
|
||||
val idBytes = value.sliceArray(pos until pos + 8)
|
||||
result.add(bytesToPeerIdHex(idBytes))
|
||||
pos += 8
|
||||
}
|
||||
return result // present (possibly empty)
|
||||
}
|
||||
}
|
||||
// Not present
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
|
||||
val clean = hexString.lowercase().take(16)
|
||||
val result = ByteArray(8) { 0 }
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < clean.length && out < 8) {
|
||||
val byteStr = clean.substring(idx, idx + 2)
|
||||
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun bytesToPeerIdHex(bytes: ByteArray): String {
|
||||
val sb = StringBuilder()
|
||||
for (b in bytes.take(8)) {
|
||||
sb.append(String.format("%02x", b))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Maintains an internal graph of the mesh based on gossip.
|
||||
* Nodes are peers (peerID), edges are direct connections.
|
||||
*/
|
||||
class MeshGraphService private constructor() {
|
||||
data class GraphNode(val peerID: String, val nickname: String?)
|
||||
data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null)
|
||||
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
|
||||
|
||||
// Map peerID -> nickname (may be null if unknown)
|
||||
private val nicknames = ConcurrentHashMap<String, String?>()
|
||||
// Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
|
||||
private val announcements = ConcurrentHashMap<String, Set<String>>()
|
||||
// Latest announcement timestamp per peer (ULong from packet)
|
||||
private val lastUpdate = ConcurrentHashMap<String, ULong>()
|
||||
|
||||
private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList()))
|
||||
val graphState: StateFlow<GraphSnapshot> = _graphState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Update graph from a verified announcement.
|
||||
* Replaces previous neighbors for origin if this is newer (by timestamp).
|
||||
*/
|
||||
fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List<String>?, timestamp: ULong) {
|
||||
synchronized(this) {
|
||||
// Always update nickname if provided
|
||||
if (originNickname != null) nicknames[originPeerID] = originNickname
|
||||
|
||||
// 1. Check timestamp first to ensure this is the latest word from the peer
|
||||
val prevTs = lastUpdate[originPeerID]
|
||||
if (prevTs != null && prevTs >= timestamp) {
|
||||
// Older or equal update: ignore
|
||||
return
|
||||
}
|
||||
lastUpdate[originPeerID] = timestamp
|
||||
|
||||
// 2. Latest announcement determines state.
|
||||
// If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list).
|
||||
val neighbors = neighborsOrNull ?: emptyList()
|
||||
|
||||
// Filter out self-loops just in case
|
||||
val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet()
|
||||
announcements[originPeerID] = newSet
|
||||
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNickname(peerID: String, nickname: String?) {
|
||||
if (nickname == null) return
|
||||
nicknames[peerID] = nickname
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a peer from the graph completely (e.g. when stale/offline).
|
||||
*/
|
||||
fun removePeer(peerID: String) {
|
||||
synchronized(this) {
|
||||
nicknames.remove(peerID)
|
||||
announcements.remove(peerID)
|
||||
lastUpdate.remove(peerID)
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
// Collect all known nodes from nicknames and announcements
|
||||
val allNodes = mutableSetOf<String>()
|
||||
allNodes.addAll(nicknames.keys)
|
||||
announcements.forEach { (origin, neighbors) ->
|
||||
allNodes.add(origin)
|
||||
allNodes.addAll(neighbors)
|
||||
}
|
||||
|
||||
val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
|
||||
|
||||
val edges = mutableListOf<GraphEdge>()
|
||||
val processedPairs = mutableSetOf<Pair<String, String>>()
|
||||
|
||||
// We only care about connections that exist in at least one direction.
|
||||
// So iterating through all entries in `announcements` covers every declared edge.
|
||||
announcements.forEach { (source, targets) ->
|
||||
targets.forEach { target ->
|
||||
val pair = if (source <= target) source to target else target to source
|
||||
if (processedPairs.add(pair)) {
|
||||
// This is a new pair we haven't evaluated yet
|
||||
val (a, b) = pair
|
||||
val aAnnouncesB = announcements[a]?.contains(b) == true
|
||||
val bAnnouncesA = announcements[b]?.contains(a) == true
|
||||
|
||||
if (aAnnouncesB && bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = true))
|
||||
} else if (aAnnouncesB) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
|
||||
} else if (bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
|
||||
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MeshGraphService? = null
|
||||
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
|
||||
}
|
||||
|
||||
@org.jetbrains.annotations.TestOnly
|
||||
fun resetForTesting() {
|
||||
synchronized(this) {
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* Computes shortest paths on the current mesh graph snapshot using Dijkstra.
|
||||
* Assumes unit edge weights.
|
||||
*/
|
||||
object RoutePlanner {
|
||||
private const val TAG = "RoutePlanner"
|
||||
|
||||
/**
|
||||
* Return full path [src, ..., dst] if reachable, else null.
|
||||
*/
|
||||
fun shortestPath(src: String, dst: String): List<String>? {
|
||||
if (src == dst) return listOf(src)
|
||||
val snapshot = MeshGraphService.getInstance().graphState.value
|
||||
val neighbors = mutableMapOf<String, MutableSet<String>>()
|
||||
|
||||
// Only consider confirmed edges for routing
|
||||
snapshot.edges.filter { it.isConfirmed }.forEach { e ->
|
||||
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
|
||||
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
|
||||
}
|
||||
// Ensure nodes known even if isolated
|
||||
snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) }
|
||||
|
||||
if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null
|
||||
|
||||
val dist = mutableMapOf<String, Int>()
|
||||
val prev = mutableMapOf<String, String?>()
|
||||
val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
|
||||
|
||||
neighbors.keys.forEach { v ->
|
||||
dist[v] = if (v == src) 0 else Int.MAX_VALUE
|
||||
prev[v] = null
|
||||
}
|
||||
pq.add(src to 0)
|
||||
|
||||
while (pq.isNotEmpty()) {
|
||||
val top = pq.poll() ?: break
|
||||
val (u, d) = top
|
||||
if (d > (dist[u] ?: Int.MAX_VALUE)) continue
|
||||
if (u == dst) break
|
||||
neighbors[u]?.forEach { v ->
|
||||
val alt = d + 1
|
||||
if (alt < (dist[v] ?: Int.MAX_VALUE)) {
|
||||
dist[v] = alt
|
||||
prev[v] = u
|
||||
pq.add(v to alt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null
|
||||
|
||||
val path = mutableListOf<String>()
|
||||
var cur: String? = dst
|
||||
while (cur != null) {
|
||||
path.add(cur)
|
||||
cur = prev[cur]
|
||||
}
|
||||
path.reverse()
|
||||
Log.d(TAG, "Computed path $path")
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,10 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.NetworkCheck
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -27,15 +24,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
@@ -217,10 +213,6 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
@@ -236,12 +228,9 @@ fun AboutSheet(
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
|
||||
@@ -37,22 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reactive helper to compute favorite state from fingerprint mapping
|
||||
* This eliminates the need for static isFavorite parameters and makes
|
||||
* the UI reactive to fingerprint manager changes
|
||||
*/
|
||||
@Composable
|
||||
fun isFavoriteReactive(
|
||||
peerID: String,
|
||||
peerFingerprints: Map<String, String>,
|
||||
favoritePeers: Set<String>
|
||||
): Boolean {
|
||||
return remember(peerID, peerFingerprints, favoritePeers) {
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun TorStatusDot(
|
||||
@@ -246,39 +231,6 @@ fun ChatHeaderContent(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header - Fully reactive state tracking
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation - no more static lookups!
|
||||
val isFavorite = isFavoriteReactive(
|
||||
peerID = selectedPrivatePeer,
|
||||
peerFingerprints = peerFingerprints,
|
||||
favoritePeers = favoritePeers
|
||||
)
|
||||
val sessionState = peerSessionStates[selectedPrivatePeer]
|
||||
|
||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||
|
||||
// Pass geohash context and people for NIP-17 chat title formatting
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
PrivateChatHeader(
|
||||
peerID = selectedPrivatePeer,
|
||||
peerNicknames = peerNicknames,
|
||||
isFavorite = isFavorite,
|
||||
sessionState = sessionState,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onBackClick = onBackClick,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
currentChannel != null -> {
|
||||
// Channel header
|
||||
ChannelHeader(
|
||||
@@ -304,148 +256,7 @@ fun ChatHeaderContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivateChatHeader(
|
||||
peerID: String,
|
||||
peerNicknames: Map<String, String>,
|
||||
isFavorite: Boolean,
|
||||
sessionState: String?,
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||
try {
|
||||
if (isNostrDM) return@remember false
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
|
||||
} else false
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||
val titleText: String = if (isNostrDM) {
|
||||
// For geohash DMs, get the actual source geohash and proper display name
|
||||
val (conversationGeohash, baseName) = try {
|
||||
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
||||
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
||||
val displayName = if (fullPubkey.isNotEmpty()) {
|
||||
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
||||
} else {
|
||||
peerNicknames[peerID] ?: "unknown"
|
||||
}
|
||||
Pair(gh, displayName)
|
||||
} catch (e: Exception) {
|
||||
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
||||
}
|
||||
|
||||
"#$conversationGeohash/@$baseName"
|
||||
} else {
|
||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||
peerNicknames[peerID] ?: run {
|
||||
val titleFromFavorites = try {
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
titleFromFavorites ?: peerID.take(12)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Back button - positioned all the way to the left with minimal margin
|
||||
Button(
|
||||
onClick = onBackClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = (-8).dp) // Move even further left to minimize margin
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_back),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title - perfectly centered regardless of other elements
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFFF9500) // Orange
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
||||
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||
if (showGlobe) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||
)
|
||||
} else {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Favorite button - positioned on the right
|
||||
IconButton(
|
||||
onClick = {
|
||||
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
onToggleFavorite()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
|
||||
@@ -5,7 +5,6 @@ package com.bitchat.android.ui
|
||||
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
@@ -51,12 +50,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@@ -85,8 +87,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Determine what messages to show based on current context (unified timelines)
|
||||
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
|
||||
val displayMessages = when {
|
||||
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
||||
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
||||
else -> {
|
||||
val locationChannel = selectedLocationChannel
|
||||
@@ -101,7 +103,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
|
||||
// Determine whether to show media buttons (only hide in geohash location chats)
|
||||
val showMediaButtons = when {
|
||||
selectedPrivatePeer != null -> true
|
||||
currentChannel != null -> true
|
||||
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
|
||||
}
|
||||
@@ -231,7 +232,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
selection = TextRange(mentionText.length)
|
||||
)
|
||||
},
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
@@ -242,12 +243,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
ChatFloatingHeader(
|
||||
headerHeight = headerHeight,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showSidebar() },
|
||||
onSidebarToggle = { viewModel.showMeshPeerList() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||
@@ -264,28 +265,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (showSidebar) 0.5f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EaseOutCubic
|
||||
), label = "overlayAlpha"
|
||||
)
|
||||
|
||||
// Only render the background if it's visible
|
||||
if (alpha > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = alpha))
|
||||
.clickable { viewModel.hideSidebar() }
|
||||
.zIndex(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp && !showSidebar,
|
||||
visible = isScrolledUp,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -311,25 +293,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSidebar,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(300, easing = EaseOutCubic)
|
||||
) + fadeIn(animationSpec = tween(300)),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(250, easing = EaseInCubic)
|
||||
) + fadeOut(animationSpec = tween(250)),
|
||||
modifier = Modifier.zIndex(2f)
|
||||
) {
|
||||
SidebarOverlay(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { viewModel.hideSidebar() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
||||
@@ -373,12 +336,18 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
},
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
|
||||
showMeshPeerListSheet = showMeshPeerListSheet,
|
||||
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputSection(
|
||||
fun ChatInputSection(
|
||||
messageText: TextFieldValue,
|
||||
onMessageTextChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
@@ -513,8 +482,16 @@ private fun ChatDialogs(
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
showSecurityVerificationSheet: Boolean,
|
||||
onSecurityVerificationSheetDismiss: () -> Unit,
|
||||
showMeshPeerListSheet: Boolean,
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
show = showPasswordDialog,
|
||||
@@ -567,4 +544,44 @@ private fun ChatDialogs(
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// MeshPeerList sheet (network view)
|
||||
if (showMeshPeerListSheet){
|
||||
MeshPeerListSheet(
|
||||
isPresented = showMeshPeerListSheet,
|
||||
viewModel = viewModel,
|
||||
onDismiss = onMeshPeerListDismiss,
|
||||
onShowVerification = {
|
||||
onMeshPeerListDismiss()
|
||||
viewModel.showVerificationSheet(fromSidebar = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerificationSheet) {
|
||||
VerificationSheet(
|
||||
isPresented = showVerificationSheet,
|
||||
onDismiss = onVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerificationSheet) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = showSecurityVerificationSheet,
|
||||
onDismiss = onSecurityVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (privateChatSheetPeer != null) {
|
||||
PrivateChatSheet(
|
||||
isPresented = true,
|
||||
peerID = privateChatSheetPeer!!,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.hidePrivateChatSheet()
|
||||
viewModel.endPrivateChat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,7 @@ class ChatState(
|
||||
|
||||
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||
|
||||
// Sidebar state
|
||||
private val _showSidebar = MutableStateFlow(false)
|
||||
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
||||
|
||||
|
||||
// Command autocomplete
|
||||
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||
@@ -122,6 +118,18 @@ class ChatState(
|
||||
// Navigation state
|
||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||
|
||||
private val _showMeshPeerList = MutableStateFlow(false)
|
||||
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
|
||||
|
||||
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
|
||||
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
|
||||
|
||||
private val _showVerificationSheet = MutableStateFlow(false)
|
||||
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
||||
|
||||
private val _showSecurityVerificationSheet = MutableStateFlow(false)
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
|
||||
|
||||
// Location channels state (for Nostr geohash features)
|
||||
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
@@ -149,7 +157,7 @@ class ChatState(
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
|
||||
val hasUnreadPrivateMessages: StateFlow<Boolean> = _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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -16,7 +15,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
@@ -36,23 +35,18 @@ fun ChatUserSheet(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
// iOS system colors (matches LocationChannelsSheet exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple
|
||||
val standardRed = Color(0xFFFF3B30) // iOS red
|
||||
val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
@@ -99,6 +93,33 @@ fun ChatUserSheet(
|
||||
|
||||
// Only show user actions for other users' messages or when no message is selected
|
||||
if (selectedMessage?.sender != viewModel.nickname.value) {
|
||||
// Send private message action
|
||||
item {
|
||||
UserActionRow(
|
||||
title = stringResource(R.string.action_private_message_title, targetNickname),
|
||||
subtitle = stringResource(R.string.action_private_message_subtitle),
|
||||
titleColor = standardPurple,
|
||||
onClick = {
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
|
||||
val shortId = selectedMessage.senderPeerID!!.substring(6)
|
||||
viewModel.startGeohashDMByShortId(shortId)
|
||||
} else {
|
||||
viewModel.startGeohashDMByNickname(targetNickname)
|
||||
}
|
||||
} else {
|
||||
// Mesh chat
|
||||
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Slap action
|
||||
item {
|
||||
UserActionRow(
|
||||
|
||||
@@ -5,11 +5,16 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@@ -19,6 +24,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@@ -26,8 +38,12 @@ import kotlin.random.Random
|
||||
*/
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
val meshService: BluetoothMeshService
|
||||
initialMeshService: BluetoothMeshService
|
||||
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||
|
||||
// Made var to support mesh service replacement after panic clear
|
||||
var meshService: BluetoothMeshService = initialMeshService
|
||||
private set
|
||||
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||
|
||||
companion object {
|
||||
@@ -46,6 +62,20 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun getCurrentNpub(): String? {
|
||||
return try {
|
||||
NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(getApplication())
|
||||
?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String {
|
||||
return VerificationService.buildMyQRString(nickname, npub) ?: ""
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
private val state = ChatState(
|
||||
scope = viewModelScope,
|
||||
@@ -57,6 +87,7 @@ class ChatViewModel(
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
@@ -75,8 +106,19 @@ class ChatViewModel(
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
getMeshService = { meshService },
|
||||
identityManager = identityManager,
|
||||
state = state,
|
||||
notificationManager = notificationManager,
|
||||
messageManager = messageManager
|
||||
)
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
@@ -120,7 +162,6 @@ class ChatViewModel(
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
||||
val hasUnreadChannels = state.hasUnreadChannels
|
||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||
@@ -134,6 +175,10 @@ class ChatViewModel(
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
|
||||
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
@@ -247,6 +292,9 @@ class ChatViewModel(
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Load verified fingerprints from secure storage
|
||||
verificationHandler.loadVerifiedFingerprints()
|
||||
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
@@ -361,6 +409,8 @@ class ChatViewModel(
|
||||
setCurrentPrivateChatPeer(null)
|
||||
// Clear mesh mention notifications since user is now back in mesh chat
|
||||
clearMeshMentionNotifications()
|
||||
// Ensure sheet is hidden
|
||||
hidePrivateChatSheet()
|
||||
}
|
||||
|
||||
// MARK: - Open Latest Unread Private Chat
|
||||
@@ -409,12 +459,7 @@ class ChatViewModel(
|
||||
canonical ?: targetKey
|
||||
}
|
||||
|
||||
startPrivateChat(openPeer)
|
||||
|
||||
// If sidebar visible, hide it to focus on the private chat
|
||||
if (state.getShowSidebarValue()) {
|
||||
state.setShowSidebar(false)
|
||||
}
|
||||
showPrivateChatSheet(openPeer)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||
}
|
||||
@@ -468,6 +513,10 @@ class ChatViewModel(
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, meshService)
|
||||
// If we're in the private chat sheet, update its active peer too
|
||||
if (state.getPrivateChatSheetPeerValue() != null) {
|
||||
showPrivateChatSheet(canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Send private message
|
||||
@@ -651,6 +700,18 @@ class ChatViewModel(
|
||||
// Update fingerprint mappings from centralized manager
|
||||
val fingerprints = privateChatManager.getAllPeerFingerprints()
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
fingerprints.forEach { (peerID, fingerprint) ->
|
||||
identityManager.cachePeerFingerprint(peerID, fingerprint)
|
||||
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
|
||||
}
|
||||
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(fingerprint, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge nicknames from BLE and 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<String>): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
|
||||
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
verificationHandler.unverifyFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
||||
return verificationHandler.beginQRVerification(qr)
|
||||
}
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
@@ -680,41 +769,87 @@ class ChatViewModel(
|
||||
return meshService.getDebugStatus()
|
||||
}
|
||||
|
||||
// Note: Mesh service restart is now handled by MainActivity
|
||||
// This function is no longer needed
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
// Forward to notification manager for notification logic
|
||||
notificationManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
// Update notification manager with current private chat peer
|
||||
notificationManager.setCurrentPrivateChatPeer(peerID)
|
||||
}
|
||||
|
||||
fun setCurrentGeohash(geohash: String?) {
|
||||
// Update notification manager with current geohash for notification logic
|
||||
notificationManager.setCurrentGeohash(geohash)
|
||||
}
|
||||
|
||||
fun clearNotificationsForSender(peerID: String) {
|
||||
// Clear notifications when user opens a chat
|
||||
notificationManager.clearNotificationsForSender(peerID)
|
||||
}
|
||||
|
||||
fun clearNotificationsForGeohash(geohash: String) {
|
||||
// Clear notifications when user opens a geohash chat
|
||||
notificationManager.clearNotificationsForGeohash(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mesh mention notifications when user opens mesh chat
|
||||
*/
|
||||
fun clearMeshMentionNotifications() {
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
private var reopenSidebarAfterVerification = false
|
||||
|
||||
fun showVerificationSheet(fromSidebar: Boolean = false) {
|
||||
if (fromSidebar) {
|
||||
reopenSidebarAfterVerification = true
|
||||
}
|
||||
state.setShowVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideVerificationSheet() {
|
||||
state.setShowVerificationSheet(false)
|
||||
if (reopenSidebarAfterVerification) {
|
||||
reopenSidebarAfterVerification = false
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(false)
|
||||
}
|
||||
|
||||
fun showMeshPeerList() {
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
|
||||
fun hideMeshPeerList() {
|
||||
state.setShowMeshPeerList(false)
|
||||
}
|
||||
|
||||
fun showPrivateChatSheet(peerID: String) {
|
||||
state.setPrivateChatSheetPeer(peerID)
|
||||
}
|
||||
|
||||
fun hidePrivateChatSheet() {
|
||||
state.setPrivateChatSheetPeer(null)
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return verificationHandler.getMyFingerprint()
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.verifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.unverifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
// MARK: - Command Autocomplete (delegated)
|
||||
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.bitchat.android.ui
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
@@ -12,11 +16,18 @@ import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.security.SecureRandom
|
||||
import kotlin.random.asKotlinRandom
|
||||
|
||||
class GeohashViewModel(
|
||||
application: Application,
|
||||
@@ -26,9 +37,12 @@ class GeohashViewModel(
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val dataManager: DataManager,
|
||||
private val notificationManager: NotificationManager
|
||||
) : AndroidViewModel(application) {
|
||||
) : AndroidViewModel(application), DefaultLifecycleObserver {
|
||||
|
||||
companion object { private const val TAG = "GeohashViewModel" }
|
||||
companion object {
|
||||
private const val TAG = "GeohashViewModel"
|
||||
private val secureRandom = SecureRandom().asKotlinRandom()
|
||||
}
|
||||
|
||||
private val repo = GeohashRepository(application, state, dataManager)
|
||||
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||
@@ -53,7 +67,9 @@ class GeohashViewModel(
|
||||
private var currentGeohashSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
private var geoTimer: Job? = null
|
||||
private var globalPresenceJob: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
private val activeSamplingGeohashes = mutableSetOf<String>()
|
||||
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
@@ -61,6 +77,10 @@ class GeohashViewModel(
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
// Observe process lifecycle to manage background sampling
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
}
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
if (identity != null) {
|
||||
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||
@@ -84,6 +104,10 @@ class GeohashViewModel(
|
||||
state.setIsTeleported(teleported)
|
||||
}
|
||||
}
|
||||
|
||||
// Start global presence heartbeat loop
|
||||
startGlobalPresenceHeartbeat()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
@@ -91,17 +115,77 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGlobalPresenceHeartbeat() {
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
// Reactively restart heartbeat whenever available channels change
|
||||
locationChannelManager?.availableChannels?.collectLatest { channels ->
|
||||
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
|
||||
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
|
||||
|
||||
if (targetGeohashes.isNotEmpty()) {
|
||||
// Enter heartbeat loop for this set of channels
|
||||
// If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately
|
||||
while (true) {
|
||||
// Randomize loop interval (40-80s, average 60s)
|
||||
val loopInterval = secureRandom.nextLong(40000L, 80000L)
|
||||
var timeSpent = 0L
|
||||
|
||||
try {
|
||||
Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels")
|
||||
targetGeohashes.forEach { geohash ->
|
||||
// Decorrelate individual broadcasts with random delay (1s-5s)
|
||||
val stepDelay = secureRandom.nextLong(1000L, 10000L)
|
||||
delay(stepDelay)
|
||||
timeSpent += stepDelay
|
||||
|
||||
broadcastPresence(geohash)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
|
||||
}
|
||||
|
||||
// Wait remaining time to satisfy target average cadence
|
||||
val remaining = loopInterval - timeSpent
|
||||
if (remaining > 0) {
|
||||
delay(remaining)
|
||||
} else {
|
||||
delay(10000L) // Minimum guard delay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun panicReset() {
|
||||
repo.clearAll()
|
||||
GeohashAliasRegistry.clear()
|
||||
GeohashConversationRegistry.clear()
|
||||
subscriptionManager.disconnect()
|
||||
currentGeohashSubId = null
|
||||
currentDmSubId = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = null
|
||||
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||
initialize()
|
||||
}
|
||||
|
||||
private suspend fun broadcastPresence(geohash: String) {
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
// Presence is lightweight, send to geohash relays
|
||||
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
|
||||
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -141,22 +225,46 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
if (geohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||
viewModelScope.launch {
|
||||
geohashes.forEach { geohash ->
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
if (geohashes.isEmpty()) {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
|
||||
// Diffing logic to avoid redundant REQ and leaks
|
||||
val currentSet = activeSamplingGeohashes.toSet()
|
||||
val newSet = geohashes.toSet()
|
||||
|
||||
val toRemove = currentSet - newSet
|
||||
val toAdd = newSet - currentSet
|
||||
|
||||
if (toAdd.isEmpty() && toRemove.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
|
||||
|
||||
// Remove old subscriptions
|
||||
toRemove.forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
activeSamplingGeohashes.remove(geohash)
|
||||
}
|
||||
|
||||
// Add new subscriptions
|
||||
activeSamplingGeohashes.addAll(toAdd)
|
||||
if (isAppInForeground()) {
|
||||
toAdd.forEach { geohash ->
|
||||
performSubscribeSampling(geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
||||
fun endGeohashSampling() {
|
||||
if (activeSamplingGeohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
|
||||
|
||||
activeSamplingGeohashes.toList().forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
}
|
||||
activeSamplingGeohashes.clear()
|
||||
}
|
||||
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||
|
||||
@@ -168,12 +276,31 @@ class GeohashViewModel(
|
||||
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
if (!gh.isNullOrEmpty()) {
|
||||
repo.setConversationGeohash(convKey, gh)
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||
GeohashConversationRegistry.set(convKey, gh)
|
||||
}
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
|
||||
}
|
||||
|
||||
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val pubkey = repo.findPubkeyByNickname(nickname)
|
||||
if (pubkey != null) {
|
||||
startGeohashDM(pubkey, onStartPrivateChat)
|
||||
} else {
|
||||
Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo")
|
||||
// Optionally notify user
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val pubkey = repo.findPubkeyByShortId(shortId)
|
||||
if (pubkey != null) {
|
||||
startGeohashDM(pubkey, onStartPrivateChat)
|
||||
} else {
|
||||
Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo")
|
||||
}
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
@@ -206,6 +333,7 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||
@@ -234,7 +362,7 @@ class GeohashViewModel(
|
||||
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||
// We don't update participant here anymore; presence loop handles it via Kind 20001
|
||||
val teleported = state.isTeleported.value
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
@@ -260,7 +388,7 @@ class GeohashViewModel(
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
@@ -279,4 +407,35 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes")
|
||||
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes")
|
||||
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
|
||||
}
|
||||
|
||||
private fun performSubscribeSampling(geohash: String) {
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -39,7 +38,9 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -62,7 +63,9 @@ fun LocationChannelsSheet(
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe bookmarks state
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
@@ -113,43 +116,27 @@ fun LocationChannelsSheet(
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp)
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp)
|
||||
) {
|
||||
// Header Section
|
||||
item(key = "header") {
|
||||
Column(
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Permission controls if services enabled
|
||||
@@ -163,24 +150,7 @@ fun LocationChannelsSheet(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.grant_location_permission),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
LocationChannelManager.PermissionState.DENIED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_permission_denied),
|
||||
@@ -212,20 +182,6 @@ fun LocationChannelsSheet(
|
||||
color = standardGreen
|
||||
)
|
||||
}
|
||||
null -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.checking_permissions),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,10 +243,17 @@ fun LocationChannelsSheet(
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.finding_nearby_channels),
|
||||
fontSize = 12.sp,
|
||||
@@ -545,50 +508,45 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
BitchatSheetTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = stringResource(R.string.location_channels_title)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
|
||||
// Lifecycle management: when presented, manage location updates
|
||||
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
|
||||
if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
onDispose {
|
||||
locationManager.endLiveRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Sampling management: update sampling when channels/bookmarks change
|
||||
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||||
if (isPresented) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
locationManager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
|
||||
// React to permission changes
|
||||
LaunchedEffect(permissionState) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
// React to location services enable/disable
|
||||
LaunchedEffect(locationServicesEnabled) {
|
||||
if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
// Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,7 +665,16 @@ private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
@Composable
|
||||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
|
||||
// For high precision channels (Neighborhood, Block) where we don't broadcast presence,
|
||||
// show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
|
||||
val isHighPrecision = channel.level.precision > 5
|
||||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||
} else {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
}
|
||||
|
||||
val levelName = when (channel.level) {
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
||||
@@ -722,7 +689,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
|
||||
@Composable
|
||||
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
val level = levelForLength(geohash.length)
|
||||
val isHighPrecision = level.precision > 5
|
||||
|
||||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||
} else {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
}
|
||||
|
||||
return "#$geohash [$peopleText]"
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ fun LocationNotesButton(
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -12,9 +13,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -25,6 +26,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
@@ -49,7 +53,6 @@ fun LocationNotesSheet(
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
// iOS color scheme
|
||||
val backgroundColor = if (isDark) Color.Black else Color.White
|
||||
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
|
||||
|
||||
// Managers
|
||||
@@ -76,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
|
||||
|
||||
@@ -9,9 +9,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Presenter component for LocationNotesSheet
|
||||
@@ -27,6 +32,8 @@ fun LocationNotesSheetPresenter(
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
@@ -44,6 +51,8 @@ fun LocationNotesSheetPresenter(
|
||||
nickname = nickname,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) {
|
||||
LocationNotesAcquiringSheet(onDismiss = onDismiss)
|
||||
} else {
|
||||
// No building geohash available - show error state (matches iOS)
|
||||
LocationNotesErrorSheet(
|
||||
@@ -53,6 +62,43 @@ fun LocationNotesSheetPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading sheet when location is being acquired
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LocationNotesAcquiringSheet(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Acquiring Location",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Please wait while your location is being determined",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error sheet when location is unavailable
|
||||
*/
|
||||
@@ -62,39 +108,50 @@ private fun LocationNotesErrorSheet(
|
||||
onDismiss: () -> Unit,
|
||||
locationManager: LocationChannelManager
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Location permission is required for notes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
// UNIFIED FIX: Enable location services first (user toggle)
|
||||
locationManager.enableLocationServices()
|
||||
// Then request location channels (which will also request permission if needed)
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 80.dp, bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Location permission is required for notes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
// UNIFIED FIX: Enable location services first (user toggle)
|
||||
locationManager.enableLocationServices()
|
||||
// Then request location channels (which will also request permission if needed)
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
}
|
||||
}
|
||||
|
||||
BitchatSheetTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = stringResource(R.string.cd_location_notes).uppercase()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ class MediaSendingManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val channelManager: ChannelManager,
|
||||
private val meshService: BluetoothMeshService
|
||||
private val getMeshService: () -> BluetoothMeshService
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
private val meshService: BluetoothMeshService
|
||||
get() = getMeshService()
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.R
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
|
||||
|
||||
/**
|
||||
* Sheet components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeshPeerListSheet(
|
||||
isPresented: Boolean,
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onShowVerification: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
// Scroll state for animated top bar
|
||||
val listState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||
) {
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item(key = "channels_header") {
|
||||
Text(
|
||||
text = stringResource(id = R.string.channels).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = joinedChannels.toList(),
|
||||
key = { "channel_$it" }
|
||||
) { channel ->
|
||||
val isSelected = channel == currentChannel
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
isSelected = isSelected,
|
||||
unreadCount = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = {
|
||||
// Check if this is a DM channel (starts with @)
|
||||
if (channel.startsWith("@")) {
|
||||
// Extract peer name and find the peer ID
|
||||
val peerName = channel.removePrefix("@")
|
||||
val peerID =
|
||||
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
// Regular channel switch
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLeaveChannel = {
|
||||
viewModel.leaveChannel(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item(key = "people_section") {
|
||||
when (selectedLocationChannel) {
|
||||
is ChannelID.Location -> {
|
||||
// Show geohash people list when in location channel
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
BitchatSheetTopBar(
|
||||
title = {
|
||||
BitchatSheetTitle(text = stringResource(id = R.string.your_network))
|
||||
},
|
||||
backgroundAlpha = topBarAlpha,
|
||||
actions = {
|
||||
if (selectedLocationChannel !is ChannelID.Location) {
|
||||
IconButton(
|
||||
onClick = onShowVerification,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: String,
|
||||
isSelected: Boolean,
|
||||
unreadCount: Int,
|
||||
colorScheme: ColorScheme,
|
||||
onChannelClick: () -> Unit,
|
||||
onLeaveChannel: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onChannelClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Unread badge
|
||||
if (unreadCount > 0) {
|
||||
UnreadBadge(
|
||||
count = unreadCount,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = channel,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Leave channel button
|
||||
CloseButton(
|
||||
onClick = onLeaveChannel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun PeopleSection(
|
||||
modifier: Modifier = Modifier,
|
||||
connectedPeers: List<String>,
|
||||
peerNicknames: Map<String, String>,
|
||||
peerRSSI: Map<String, Int>,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
// Reactive favorite computation - same as ChatHeader
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
}
|
||||
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
val sortedPeers = connectedPeers.sortedWith(
|
||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
||||
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
||||
)
|
||||
|
||||
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
// Helper to compute display name used for a given key
|
||||
fun computeDisplayNameForPeerId(key: String): String {
|
||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
||||
}
|
||||
|
||||
val baseNameCounts = mutableMapOf<String, Int>()
|
||||
|
||||
// Connected peers
|
||||
sortedPeers.forEach { pid ->
|
||||
val dn = computeDisplayNameForPeerId(pid)
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
// Offline favorites (exclude ones mapped to connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (!isMappedToConnected) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Nostr-only conversations
|
||||
val connectedIds = sortedPeers.toSet()
|
||||
val appendedOfflineIds = mutableSetOf<String>()
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
||||
!connectedIds.contains(key) &&
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
}
|
||||
.forEach { convKey ->
|
||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
|
||||
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
|
||||
val combinedHasUnread = meshUnread || nostrUnread
|
||||
val combinedUnreadCount = (
|
||||
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
||||
) + (
|
||||
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
|
||||
)
|
||||
|
||||
val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12)))
|
||||
val (bName, _) = splitSuffix(displayName)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = displayName,
|
||||
isDirect = isDirectLive,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
viewModel.toggleFavorite(peerID)
|
||||
},
|
||||
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
|
||||
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||
val nostrConvKey: String? = try {
|
||||
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
hex?.let { "nostr_${it.take(16)}" }
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||
|
||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
||||
// open chat with the connected peerID instead of the noise hex for a seamless window
|
||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (bName, _) = splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||
) + (
|
||||
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||
)
|
||||
|
||||
PeerItem(
|
||||
peerID = favPeerID,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
||||
viewModel.toggleFavorite(favPeerID)
|
||||
},
|
||||
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
appendedOfflineIds.add(favPeerID)
|
||||
}
|
||||
|
||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||
/*
|
||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||
hex64Regex.matches(key) &&
|
||||
!alreadyShownIds.contains(key) &&
|
||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
}
|
||||
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
|
||||
.forEach { convKey ->
|
||||
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
|
||||
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
|
||||
val (bName, _) = splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(convKey) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
||||
unreadCount = privateChats[convKey]?.count { msg ->
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
*/
|
||||
// End intentional removal
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
isDirect: Boolean,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isVerified: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
unreadCount: Int = 0,
|
||||
showNostrGlobe: Boolean = false,
|
||||
showHashSuffix: Boolean = true
|
||||
) {
|
||||
val currentNickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
val isMe = displayName == "You" || peerID == currentNickname
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Surface(
|
||||
onClick = onItemClick,
|
||||
color = if (isSelected) {
|
||||
colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Connection/status indicator
|
||||
if (hasUnreadDM) {
|
||||
// Show mail icon for unread DMs (iOS orange)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else if (showNostrGlobe) {
|
||||
// Purple globe to indicate Nostr availability
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
} else if (!isDirect && isFavorite) {
|
||||
// Offline favorited user: show outlined circle icon
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = stringResource(R.string.cd_offline_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route,
|
||||
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// Display name with iOS-style color and hashtag suffix support
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Hashtag suffix in lighter shade (iOS-style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B) // iOS Green
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Favorite star with proper filled/outlined states
|
||||
IconButton(
|
||||
onClick = onToggleFavorite,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable unread badge component for both channels and private messages
|
||||
*/
|
||||
@Composable
|
||||
private fun UnreadBadge(
|
||||
count: Int,
|
||||
colorScheme: ColorScheme,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (count > 0) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = Color(0xFFFFD700), // Yellow color
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (count > 99) "99+" else count.toString(),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = Color.Black // Black text on yellow background
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RSSI value (dBm) to signal strength percentage (0-100)
|
||||
* RSSI typically ranges from -30 (excellent) to -100 (very poor)
|
||||
* Maps to 0-100 scale where:
|
||||
* - 0-32: No signal (0 bars)
|
||||
* - 33-65: Weak (1 bar)
|
||||
* - 66-98: Good (2 bars)
|
||||
* - 99-100: Excellent (3 bars)
|
||||
*/
|
||||
private fun convertRSSIToSignalStrength(rssi: Int?): Int {
|
||||
if (rssi == null) return 0
|
||||
|
||||
return when {
|
||||
rssi >= -40 -> 100 // Excellent signal
|
||||
rssi >= -55 -> 85 // Very good signal
|
||||
rssi >= -70 -> 70 // Good signal
|
||||
rssi >= -85 -> 50 // Fair signal
|
||||
rssi >= -100 -> 25 // Poor signal
|
||||
else -> 0 // Very poor or no signal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested Private Chat Sheet - iOS-style nested bottom sheet
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivateChatSheet(
|
||||
isPresented: Boolean,
|
||||
peerID: String,
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
// Start private chat when screen opens
|
||||
LaunchedEffect(peerID) {
|
||||
viewModel.startPrivateChat(peerID)
|
||||
}
|
||||
|
||||
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
|
||||
// Compute display name and title text reactively
|
||||
val displayName = peerNicknames[peerID] ?: peerID.take(12)
|
||||
val titleText = remember(peerID, peerNicknames) {
|
||||
if (isNostrPeer) {
|
||||
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
|
||||
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
|
||||
val name = if (fullPubkey.isNotEmpty()) {
|
||||
viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh)
|
||||
} else {
|
||||
peerNicknames[peerID] ?: "unknown"
|
||||
}
|
||||
"#$gh/@$name"
|
||||
} else {
|
||||
peerNicknames[peerID] ?: peerID.take(12)
|
||||
}
|
||||
}
|
||||
|
||||
val messages = privateChats[peerID] ?: emptyList()
|
||||
val isDirect = peerDirectMap[peerID] == true
|
||||
val isConnected = connectedPeers.contains(peerID) || isDirect
|
||||
val sessionState = peerSessionStates[peerID]
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
val isFavorite = remember(favoritePeers, fingerprint) {
|
||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
||||
}
|
||||
|
||||
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
val securityModifier = if (!isNostrPeer) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Messages list
|
||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||
var isScrolledUp by remember { mutableStateOf(false) }
|
||||
|
||||
MessagesList(
|
||||
messages = messages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
modifier = Modifier.weight(1f),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { /* handle mention */ },
|
||||
onMessageLongPress = { /* handle long press */ },
|
||||
onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) },
|
||||
onImageClick = { _, _, _ -> /* handle image click */ }
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Input section
|
||||
var messageText by remember {
|
||||
mutableStateOf(
|
||||
androidx.compose.ui.text.input.TextFieldValue(
|
||||
""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputSection(
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText ->
|
||||
messageText = newText
|
||||
viewModel.updateMentionSuggestions(newText.text)
|
||||
},
|
||||
onSend = {
|
||||
if (messageText.text.trim().isNotEmpty()) {
|
||||
viewModel.sendMessage(messageText.text.trim())
|
||||
messageText = androidx.compose.ui.text.input.TextFieldValue("")
|
||||
forceScrollToBottom = !forceScrollToBottom
|
||||
}
|
||||
},
|
||||
onSendVoiceNote = { peer, channel, path ->
|
||||
viewModel.sendVoiceNote(peer, channel, path)
|
||||
},
|
||||
onSendImageNote = { peer, channel, path ->
|
||||
viewModel.sendImageNote(peer, channel, path)
|
||||
},
|
||||
onSendFileNote = { peer, channel, path ->
|
||||
viewModel.sendFileNote(peer, channel, path)
|
||||
},
|
||||
showCommandSuggestions = false,
|
||||
commandSuggestions = emptyList(),
|
||||
showMentionSuggestions = false,
|
||||
mentionSuggestions = emptyList(),
|
||||
onCommandSuggestionClick = { },
|
||||
onMentionSuggestionClick = { },
|
||||
selectedPrivatePeer = peerID,
|
||||
currentChannel = null,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = true
|
||||
)
|
||||
}
|
||||
|
||||
// TopBar (fixed at top, iOS-style)
|
||||
BitchatSheetCenterTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 16.dp)
|
||||
.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.chat_back),
|
||||
tint = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
// Center content: connection status + name + encryption
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isDirect -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SettingsInputAntenna,
|
||||
contentDescription = stringResource(R.string.cd_connected_peers),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isConnected -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Route,
|
||||
contentDescription = stringResource(R.string.cd_ready_for_handshake),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isNostrPeer -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9C27B0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.then(securityModifier)
|
||||
) {
|
||||
if (!isNostrPeer) {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B) // iOS Green
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,19 +71,14 @@ fun MessagesList(
|
||||
|
||||
// Track if this is the first time messages are being loaded
|
||||
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
|
||||
var followIncomingMessages by remember { mutableStateOf(true) }
|
||||
|
||||
// Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom
|
||||
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
||||
|
||||
// With reverseLayout=true and reversed data, index 0 is the latest message at the bottom
|
||||
val isFirstLoad = !hasScrolledToInitialPosition
|
||||
val isNearLatest = firstVisibleIndex <= 2
|
||||
|
||||
if (isFirstLoad || isNearLatest) {
|
||||
listState.animateScrollToItem(0)
|
||||
if (isFirstLoad || followIncomingMessages) {
|
||||
listState.scrollToItem(0)
|
||||
if (isFirstLoad) {
|
||||
hasScrolledToInitialPosition = true
|
||||
}
|
||||
@@ -99,6 +94,7 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(isAtLatest) {
|
||||
followIncomingMessages = isAtLatest
|
||||
onScrolledUpChanged?.invoke(!isAtLatest)
|
||||
}
|
||||
|
||||
@@ -106,7 +102,8 @@ fun MessagesList(
|
||||
LaunchedEffect(forceScrollToBottom) {
|
||||
if (messages.isNotEmpty()) {
|
||||
// With reverseLayout=true and reversed data, latest is at index 0
|
||||
listState.animateScrollToItem(0)
|
||||
followIncomingMessages = true
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,37 @@ class NotificationManager(
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
|
||||
}
|
||||
|
||||
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (peerID != null) {
|
||||
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
|
||||
putExtra(EXTRA_PEER_ID, peerID)
|
||||
putExtra(EXTRA_SENDER_NICKNAME, body)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -298,6 +298,16 @@ class PrivateChatManager(
|
||||
if (!isPeerBlocked(senderPeerID)) {
|
||||
// Ensure chat exists
|
||||
messageManager.initializePrivateChat(senderPeerID)
|
||||
|
||||
// Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here.
|
||||
if (senderPeerID.startsWith("nostr_")) {
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Track as unread for read receipt purposes if not focused
|
||||
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
@@ -474,6 +484,12 @@ class PrivateChatManager(
|
||||
unread.add(targetPeerID)
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
}
|
||||
|
||||
// If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.NoEncryption
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
|
||||
private data class SecurityStatusInfo(
|
||||
val text: String,
|
||||
val icon: ImageVector,
|
||||
val tint: Color
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SecurityVerificationSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!isPresented) return
|
||||
|
||||
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
|
||||
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
|
||||
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SecurityVerificationHeader(
|
||||
accent = accent,
|
||||
onClose = onDismiss
|
||||
)
|
||||
|
||||
if (peerID == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_no_peer),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
} else {
|
||||
val selectedPeerID = peerID!!
|
||||
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
|
||||
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
|
||||
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
val sessionState = peerSessionStates[selectedPeerID]
|
||||
val statusInfo = buildStatusInfo(
|
||||
isVerified = isVerified,
|
||||
sessionState = sessionState,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
SecurityStatusCard(
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
boxColor = boxColor,
|
||||
statusInfo = statusInfo
|
||||
)
|
||||
|
||||
FingerprintBlock(
|
||||
title = stringResource(R.string.fingerprint_their),
|
||||
fingerprint = fingerprint,
|
||||
boxColor = boxColor,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
FingerprintBlock(
|
||||
title = stringResource(R.string.fingerprint_yours),
|
||||
fingerprint = viewModel.getMyFingerprint(),
|
||||
boxColor = boxColor,
|
||||
accent = accent
|
||||
)
|
||||
|
||||
SecurityVerificationActions(
|
||||
isVerified = isVerified,
|
||||
fingerprint = fingerprint,
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
|
||||
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
|
||||
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
|
||||
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityVerificationHeader(
|
||||
accent: Color,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.security_verification_title),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
CloseButton(onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun buildStatusInfo(
|
||||
isVerified: Boolean,
|
||||
sessionState: String?,
|
||||
accent: Color
|
||||
): SecurityStatusInfo {
|
||||
val text = when {
|
||||
isVerified -> stringResource(R.string.fingerprint_status_verified)
|
||||
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
|
||||
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
|
||||
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
|
||||
else -> stringResource(R.string.fingerprint_status_uninitialized)
|
||||
}
|
||||
val icon = when {
|
||||
isVerified -> Icons.Filled.Verified
|
||||
sessionState == "handshaking" -> Icons.Outlined.Sync
|
||||
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
|
||||
sessionState == "established" -> Icons.Filled.Lock
|
||||
else -> Icons.Outlined.NoEncryption
|
||||
}
|
||||
val tint = when {
|
||||
isVerified -> Color(0xFF32D74B)
|
||||
sessionState == "failed" -> Color(0xFFFF3B30)
|
||||
sessionState == "handshaking" -> Color(0xFFFF9500)
|
||||
sessionState == "established" -> Color(0xFF32D74B)
|
||||
else -> accent.copy(alpha = 0.6f)
|
||||
}
|
||||
return SecurityStatusInfo(text, icon, tint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityStatusCard(
|
||||
displayName: String,
|
||||
accent: Color,
|
||||
boxColor: Color,
|
||||
statusInfo: SecurityStatusInfo
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(boxColor, shape = MaterialTheme.shapes.medium)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = statusInfo.icon,
|
||||
contentDescription = null,
|
||||
tint = statusInfo.tint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent
|
||||
)
|
||||
Text(
|
||||
text = statusInfo.text,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityVerificationActions(
|
||||
isVerified: Boolean,
|
||||
fingerprint: String?,
|
||||
displayName: String,
|
||||
accent: Color,
|
||||
canStartHandshake: Boolean,
|
||||
onStartHandshake: () -> Unit,
|
||||
onVerify: (String) -> Unit,
|
||||
onUnverify: (String) -> Unit
|
||||
) {
|
||||
if (canStartHandshake) {
|
||||
Button(
|
||||
onClick = onStartHandshake,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accent,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_start_handshake),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Verified,
|
||||
iconTint = Color(0xFF32D74B),
|
||||
text = stringResource(R.string.fingerprint_verified_label),
|
||||
textTint = Color(0xFF32D74B)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_verified_message),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Button(
|
||||
onClick = { fingerprint?.let(onUnverify) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFFF3B30),
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VerificationStatusRow(
|
||||
icon = Icons.Filled.Warning,
|
||||
iconTint = Color(0xFFFF9500),
|
||||
text = stringResource(R.string.fingerprint_not_verified_label),
|
||||
textTint = Color(0xFFFF9500)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = accent.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (fingerprint != null) {
|
||||
Button(
|
||||
onClick = { onVerify(fingerprint) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF34C759),
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_mark_verified),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationStatusRow(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
text: String,
|
||||
textTint: Color
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = textTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerprintBlock(
|
||||
title: String,
|
||||
fingerprint: String?,
|
||||
boxColor: Color,
|
||||
accent: Color
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var showMenu by remember(fingerprint) { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = accent.copy(alpha = 0.8f)
|
||||
)
|
||||
if (fingerprint != null) {
|
||||
Column {
|
||||
Text(
|
||||
text = formatFingerprint(fingerprint),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = accent,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true }
|
||||
)
|
||||
.background(boxColor, shape = MaterialTheme.shapes.small)
|
||||
.padding(16.dp),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = stringResource(R.string.fingerprint_copy)) },
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(fingerprint))
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.fingerprint_pending),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
color = Color(0xFFFF9500),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFingerprint(fingerprint: String): String {
|
||||
val upper = fingerprint.uppercase()
|
||||
val sb = StringBuilder()
|
||||
upper.forEachIndexed { index, c ->
|
||||
if (index > 0 && index % 4 == 0) {
|
||||
if (index % 16 == 0) sb.append('\n') else sb.append(' ')
|
||||
}
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.R
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
|
||||
|
||||
/**
|
||||
* Sidebar components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun SidebarOverlay(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.clickable(indication = null, interactionSource = interactionSource) { onDismiss() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(280.dp)
|
||||
.align(Alignment.CenterEnd)
|
||||
.clickable { /* Prevent dismissing when clicking sidebar */ }
|
||||
) {
|
||||
// Grey vertical bar for visual continuity (matches iOS)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(1.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.background(colorScheme.background.copy(alpha = 0.95f))
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
|
||||
) {
|
||||
SidebarHeader()
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Scrollable content
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item {
|
||||
ChannelsSection(
|
||||
channels = joinedChannels.toList(), // Convert Set to List
|
||||
currentChannel = currentChannel,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = { channel ->
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
},
|
||||
onLeaveChannel = { channel ->
|
||||
viewModel.leaveChannel(channel)
|
||||
},
|
||||
unreadChannelMessages = unreadChannelMessages
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
|
||||
|
||||
when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Show geohash people list when in location channel
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
modifier = modifier.padding(bottom = 16.dp),
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarHeader() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(42.dp) // Match reduced main header height
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.background.copy(alpha = 0.95f))
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.your_network).uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelsSection(
|
||||
channels: List<String>,
|
||||
currentChannel: String?,
|
||||
colorScheme: ColorScheme,
|
||||
onChannelClick: (String) -> Unit,
|
||||
onLeaveChannel: (String) -> Unit,
|
||||
unreadChannelMessages: Map<String, Int> = emptyMap()
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person, // Using Person icon as placeholder
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.channels).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
channels.forEach { channel ->
|
||||
val isSelected = channel == currentChannel
|
||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChannelClick(channel) }
|
||||
.background(
|
||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Unread badge for channels
|
||||
UnreadBadge(
|
||||
count = unreadCount,
|
||||
colorScheme = colorScheme,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = channel, // Channel already contains the # prefix
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Leave channel button
|
||||
IconButton(
|
||||
onClick = { onLeaveChannel(channel) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeopleSection(
|
||||
modifier: Modifier = Modifier,
|
||||
connectedPeers: List<String>,
|
||||
peerNicknames: Map<String, String>,
|
||||
peerRSSI: Map<String, Int>,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group, // Using Person icon for people
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_one_connected),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
// Reactive favorite computation - same as ChatHeader
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
val sortedPeers = connectedPeers.sortedWith(
|
||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
||||
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
||||
)
|
||||
|
||||
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
// Helper to compute display name used for a given key
|
||||
fun computeDisplayNameForPeerId(key: String): String {
|
||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
||||
}
|
||||
|
||||
val baseNameCounts = mutableMapOf<String, Int>()
|
||||
|
||||
// Connected peers
|
||||
sortedPeers.forEach { pid ->
|
||||
val dn = computeDisplayNameForPeerId(pid)
|
||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
// Offline favorites (exclude ones mapped to connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (!isMappedToConnected) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Nostr-only conversations
|
||||
val connectedIds = sortedPeers.toSet()
|
||||
val appendedOfflineIds = mutableSetOf<String>()
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
||||
!connectedIds.contains(key) &&
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
}
|
||||
.forEach { convKey ->
|
||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
}
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
|
||||
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
|
||||
val combinedHasUnread = meshUnread || nostrUnread
|
||||
val combinedUnreadCount = (
|
||||
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
||||
) + (
|
||||
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
|
||||
)
|
||||
|
||||
val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12)))
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = displayName,
|
||||
isDirect = isDirectLive,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
viewModel.toggleFavorite(peerID)
|
||||
},
|
||||
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
|
||||
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||
val nostrConvKey: String? = try {
|
||||
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
hex?.let { "nostr_${it.take(16)}" }
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||
|
||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
||||
// open chat with the connected peerID instead of the noise hex for a seamless window
|
||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||
) + (
|
||||
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||
)
|
||||
|
||||
PeerItem(
|
||||
peerID = favPeerID,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
||||
viewModel.toggleFavorite(favPeerID)
|
||||
},
|
||||
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
appendedOfflineIds.add(favPeerID)
|
||||
}
|
||||
|
||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||
/*
|
||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||
hex64Regex.matches(key) &&
|
||||
!alreadyShownIds.contains(key) &&
|
||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
}
|
||||
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
|
||||
.forEach { convKey ->
|
||||
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
|
||||
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(convKey) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
||||
unreadCount = privateChats[convKey]?.count { msg ->
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
*/
|
||||
// End intentional removal
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
isDirect: Boolean,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
unreadCount: Int = 0,
|
||||
showNostrGlobe: Boolean = false,
|
||||
showHashSuffix: Boolean = true
|
||||
) {
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
val isMe = displayName == "You" || peerID == viewModel.nickname.value
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onItemClick() }
|
||||
.background(
|
||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Show unread badge or signal strength
|
||||
if (hasUnreadDM) {
|
||||
// Show mail icon for unread DMs (iOS orange)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Connection indicator icons
|
||||
if (showNostrGlobe) {
|
||||
// Purple globe to indicate Nostr availability
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
} else if (!isDirect && isFavorite) {
|
||||
// Offline favorited user: show outlined circle icon
|
||||
Icon(
|
||||
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
|
||||
imageVector = Icons.Outlined.Circle,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Gray
|
||||
)
|
||||
} else {
|
||||
val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState()
|
||||
val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState()
|
||||
val isWifiDirect = awareConnected.containsKey(peerID)
|
||||
val isBleDirect = isDirect
|
||||
val icon = when {
|
||||
isWifiDirect -> Icons.Filled.Wifi
|
||||
isBleDirect -> Icons.Outlined.SettingsInputAntenna
|
||||
// Routed: show Route icon; optionally prefer 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Handles QR verification logic and state, extracted from ChatViewModel.
|
||||
*/
|
||||
class VerificationHandler(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val getMeshService: () -> BluetoothMeshService,
|
||||
private val identityManager: SecureIdentityStateManager,
|
||||
private val state: ChatState,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val messageManager: MessageManager
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
private val meshService: BluetoothMeshService
|
||||
get() = getMeshService()
|
||||
|
||||
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
|
||||
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
|
||||
|
||||
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
|
||||
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
|
||||
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
|
||||
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun loadVerifiedFingerprints() {
|
||||
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
|
||||
}
|
||||
|
||||
fun isPeerVerified(peerID: String): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
|
||||
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return _verifiedFingerprints.value.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, false)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.remove(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
||||
val targetNoise = qr.noiseKeyHex.lowercase()
|
||||
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
|
||||
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
|
||||
noiseKeyHex == targetNoise
|
||||
} ?: return false
|
||||
|
||||
if (pendingQRVerifications.containsKey(peerID)) return true
|
||||
val nonce = ByteArray(16)
|
||||
java.security.SecureRandom().nextBytes(nonce)
|
||||
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
|
||||
pendingQRVerifications[peerID] = pending
|
||||
|
||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
|
||||
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
||||
} else {
|
||||
meshService.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
|
||||
identityManager.cacheFingerprintNickname(fp, qr.nickname)
|
||||
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
|
||||
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendPendingVerificationIfNeeded(peerID: String) {
|
||||
val pending = pendingQRVerifications[peerID] ?: return
|
||||
if (pending.sent) return
|
||||
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
|
||||
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
||||
}
|
||||
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
|
||||
scope.launch {
|
||||
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
|
||||
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
|
||||
if (parsed.first.lowercase() != myNoiseHex) return@launch
|
||||
|
||||
val lastNonce = lastVerifyNonceByPeer[peerID]
|
||||
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
|
||||
lastVerifyNonceByPeer[peerID] = parsed.second
|
||||
|
||||
val fp = meshService.getPeerFingerprint(peerID)
|
||||
if (fp != null) {
|
||||
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
|
||||
if (_verifiedFingerprints.value.contains(fp)) {
|
||||
val lastToast = lastMutualToastAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
||||
lastMutualToastAt[fp] = System.currentTimeMillis()
|
||||
val name = resolvePeerDisplayName(peerID)
|
||||
val body = context.getString(R.string.verify_mutual_match_body, name)
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
|
||||
}
|
||||
}
|
||||
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
|
||||
scope.launch {
|
||||
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
|
||||
val pending = pendingQRVerifications[peerID] ?: return@launch
|
||||
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
|
||||
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
|
||||
|
||||
val ok = VerificationService.verifyResponseSignature(
|
||||
noiseKeyHex = resp.noiseKeyHex,
|
||||
nonceA = resp.nonceA,
|
||||
signature = resp.signature,
|
||||
signerPublicKeyHex = pending.signKeyHex
|
||||
)
|
||||
if (!ok) return@launch
|
||||
|
||||
pendingQRVerifications.remove(peerID)
|
||||
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
|
||||
identityManager.setVerifiedFingerprint(fp, true)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fp)
|
||||
_verifiedFingerprints.value = current
|
||||
|
||||
val name = resolvePeerDisplayName(peerID)
|
||||
identityManager.cacheFingerprintNickname(fp, name)
|
||||
val noiseKeyHex = try {
|
||||
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
|
||||
}
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
|
||||
|
||||
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
|
||||
val lastToast = lastMutualToastAt[fp] ?: 0L
|
||||
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
||||
lastMutualToastAt[fp] = System.currentTimeMillis()
|
||||
val body = context.getString(R.string.verify_mutual_match_body, name)
|
||||
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
||||
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
val fromMap = state.getPeerFingerprintsValue()[peerID]
|
||||
if (fromMap != null) return fromMap
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
return try {
|
||||
when {
|
||||
peerID.length == 64 && peerID.matches(hexRegex) -> {
|
||||
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
|
||||
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
|
||||
}
|
||||
peerID.length == 16 && peerID.matches(hexRegex) -> {
|
||||
val meshFp = meshService.getPeerFingerprint(peerID)
|
||||
if (meshFp != null) return meshFp
|
||||
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
||||
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
||||
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
||||
}
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
||||
}
|
||||
peerID.startsWith("nostr_") -> {
|
||||
val pubHex = GeohashAliasRegistry.get(peerID)
|
||||
val noiseKey = pubHex?.let {
|
||||
FavoritesPersistenceService.shared.findNoiseKey(it)
|
||||
}
|
||||
noiseKey?.let {
|
||||
val noiseHex = it.hexEncodedString()
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
||||
}
|
||||
}
|
||||
peerID.startsWith("nostr:") -> {
|
||||
val prefix = peerID.removePrefix("nostr:").lowercase()
|
||||
val pubHex = GeohashAliasRegistry
|
||||
.snapshot()
|
||||
.values
|
||||
.firstOrNull { it.lowercase().startsWith(prefix) }
|
||||
val noiseKey = pubHex?.let {
|
||||
FavoritesPersistenceService.shared.findNoiseKey(it)
|
||||
}
|
||||
noiseKey?.let {
|
||||
val noiseHex = it.hexEncodedString()
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val meshFp = meshService.getPeerFingerprint(peerID)
|
||||
if (meshFp != null) return meshFp
|
||||
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
||||
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
||||
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
||||
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
||||
}
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
val nicknameMap = state.peerNicknames.value
|
||||
nicknameMap[peerID]?.let { return it }
|
||||
try {
|
||||
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
||||
fingerprint?.let { fp ->
|
||||
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
|
||||
if (cached.isNotBlank()) return cached
|
||||
}
|
||||
}
|
||||
|
||||
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
||||
if (peerID.length == 64 && peerID.matches(hexRegex)) {
|
||||
val noiseKeyBytes = try {
|
||||
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
} catch (_: Exception) { null }
|
||||
val favorite = noiseKeyBytes?.let {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(it)
|
||||
}
|
||||
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
|
||||
if (peerID.length == 16 && peerID.matches(hexRegex)) {
|
||||
val favorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
|
||||
return peerID.take(8)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return meshService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
if (fingerprint.isBlank()) return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, true)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.add(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
if (fingerprint.isBlank()) return
|
||||
identityManager.setVerifiedFingerprint(fingerprint, false)
|
||||
val current = _verifiedFingerprints.value.toMutableSet()
|
||||
current.remove(fingerprint)
|
||||
_verifiedFingerprints.value = current
|
||||
}
|
||||
|
||||
private fun addVerificationSystemMessage(peerID: String, text: String) {
|
||||
val msg = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
messageManager.addPrivateMessageNoUnread(peerID, msg)
|
||||
}
|
||||
|
||||
private fun resolvePeerDisplayName(peerID: String): String {
|
||||
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
|
||||
return nick ?: peerID.take(8)
|
||||
}
|
||||
|
||||
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
|
||||
notificationManager.showVerificationNotification(title, body, peerID)
|
||||
}
|
||||
|
||||
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
|
||||
val bytes = noiseHex.dataFromHexString() ?: return null
|
||||
return fingerprintFromNoiseBytes(bytes)
|
||||
}
|
||||
|
||||
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return hash.hexEncodedString()
|
||||
}
|
||||
|
||||
private data class PendingVerification(
|
||||
val noiseKeyHex: String,
|
||||
val signKeyHex: String,
|
||||
val nonceA: ByteArray,
|
||||
val startedAtMs: Long,
|
||||
val sent: Boolean
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PendingVerification
|
||||
|
||||
if (startedAtMs != other.startedAtMs) return false
|
||||
if (sent != other.sent) return false
|
||||
if (noiseKeyHex != other.noiseKeyHex) return false
|
||||
if (signKeyHex != other.signKeyHex) return false
|
||||
if (!nonceA.contentEquals(other.nonceA)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = startedAtMs.hashCode()
|
||||
result = 31 * result + sent.hashCode()
|
||||
result = 31 * result + noiseKeyHex.hashCode()
|
||||
result = 31 * result + signKeyHex.hashCode()
|
||||
result = 31 * result + nonceA.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.camera.compose.CameraXViewfinder
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.viewfinder.core.ImplementationMode
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.set
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VerificationSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!isPresented) return
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = if (isDark) Color.Green else Color(0xFF008000)
|
||||
|
||||
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val npub = remember { viewModel.getCurrentNpub() }
|
||||
|
||||
val qrString = remember(nickname, npub) {
|
||||
viewModel.buildMyQRString(nickname, npub)
|
||||
}
|
||||
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
// Header
|
||||
VerificationHeader(
|
||||
accent = accent,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
// Tabs
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = accent,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
|
||||
color = accent
|
||||
)
|
||||
}
|
||||
) {
|
||||
Tab(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
text = {
|
||||
Text(
|
||||
text = "My QR",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
text = {
|
||||
Text(
|
||||
text = "Scan",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Content
|
||||
Crossfade(
|
||||
targetState = selectedTab,
|
||||
label = "VerificationTabCrossfade",
|
||||
modifier = Modifier.weight(1f)
|
||||
) { tab ->
|
||||
when (tab) {
|
||||
0 -> MyQrTabContent(
|
||||
qrString = qrString,
|
||||
nickname = nickname,
|
||||
accent = accent
|
||||
)
|
||||
1 -> ScanTabContent(
|
||||
accent = accent,
|
||||
onScan = { code ->
|
||||
val qr = VerificationService.verifyScannedQR(code)
|
||||
if (qr != null && viewModel.beginQRVerification(qr)) {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Unverify Action
|
||||
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
if (peerID != null) {
|
||||
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
|
||||
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = { viewModel.unverifyFingerprint(peerID!!) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_remove),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerificationHeader(
|
||||
accent: Color,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_title).uppercase(),
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = accent
|
||||
)
|
||||
CloseButton(onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyQrTabContent(
|
||||
qrString: String,
|
||||
nickname: String,
|
||||
accent: Color
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.verify_my_qr_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = accent
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
if (qrString.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.White)
|
||||
.padding(20.dp) // Quiet zone
|
||||
) {
|
||||
QRCodeImage(data = qrString, size = 260.dp)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(260.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.White.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_qr_unavailable),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Black.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// User Nickname
|
||||
Text(
|
||||
text = nickname,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Helper text
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).lowercase(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
private fun ScanTabContent(
|
||||
accent: Color,
|
||||
onScan: (String) -> Unit
|
||||
) {
|
||||
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (permissionState.status.isGranted) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ScannerView(onScan = onScan)
|
||||
|
||||
// Overlay border
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(280.dp)
|
||||
.border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp))
|
||||
)
|
||||
|
||||
// Corner accents for the overlay
|
||||
Box(modifier = Modifier.size(260.dp)) {
|
||||
// This could be drawn with Canvas for cooler effect, but simple border is cleaner for now
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.verify_scan_prompt_friend),
|
||||
color = Color.White,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 32.dp)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = accent
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.verify_camera_permission),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = { permissionState.launchPermissionRequest() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = accent)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.verify_request_camera),
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScannerView(
|
||||
onScan: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var lastValid by remember { mutableStateOf<String?>(null) }
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
|
||||
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
|
||||
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
val onCodeState = rememberUpdatedState(onScan)
|
||||
val analyzer = remember {
|
||||
QRCodeAnalyzer { text ->
|
||||
mainHandler.post {
|
||||
if (text == lastValid) return@post
|
||||
lastValid = text
|
||||
onCodeState.value(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
var cameraProvider: ProcessCameraProvider? = null
|
||||
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider { request -> surfaceRequests.value = request }
|
||||
}
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor, analyzer) }
|
||||
|
||||
runCatching {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
|
||||
}
|
||||
},
|
||||
executor
|
||||
)
|
||||
|
||||
onDispose {
|
||||
surfaceRequests.value = null
|
||||
runCatching { cameraProvider?.unbindAll() }
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
surfaceRequest?.let { request ->
|
||||
CameraXViewfinder(
|
||||
surfaceRequest = request,
|
||||
implementationMode = ImplementationMode.EMBEDDED,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QRCodeImage(data: String, size: Dp) {
|
||||
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
|
||||
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
|
||||
if (data.isBlank() || sizePx <= 0) return null
|
||||
return try {
|
||||
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
bitmapFromMatrix(matrix)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width
|
||||
val height = matrix.height
|
||||
val bitmap = createBitmap(width, height)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap[x, y] =
|
||||
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private class QRCodeAnalyzer(
|
||||
private val onCode: (String) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
private val scanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
@ExperimentalGetImage
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val mediaImage = imageProxy.image ?: run {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(input)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val text = barcodes.firstOrNull()?.rawValue
|
||||
if (!text.isNullOrBlank()) onCode(text)
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ package com.bitchat.android.ui.debug
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.toHexString
|
||||
|
||||
/**
|
||||
* Debug settings manager for controlling debug features and collecting debug data
|
||||
@@ -464,7 +468,9 @@ class DebugSettingsManager private constructor() {
|
||||
toNickname: String?,
|
||||
toDeviceAddress: String?,
|
||||
ttl: UByte?,
|
||||
isRelay: Boolean = true
|
||||
isRelay: Boolean = true,
|
||||
packetVersion: UByte = 1u,
|
||||
routeInfo: String? = null
|
||||
) {
|
||||
// Build message only if verbose logging is enabled, but always update stats
|
||||
val senderLabel = when {
|
||||
@@ -487,18 +493,20 @@ class DebugSettingsManager private constructor() {
|
||||
val fromAddr = fromDeviceAddress ?: "?"
|
||||
val toAddr = toDeviceAddress ?: "?"
|
||||
val ttlStr = ttl?.toString() ?: "?"
|
||||
val routeStr = if (routeInfo != null) " $routeInfo" else ""
|
||||
|
||||
if (verboseLoggingEnabled.value) {
|
||||
if (isRelay) {
|
||||
// Relay: show [previousPeer] -> [nextPeer]
|
||||
addDebugMessage(
|
||||
DebugMessage.RelayEvent(
|
||||
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
|
||||
"♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
addDebugMessage(
|
||||
DebugMessage.PacketEvent(
|
||||
"📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
|
||||
"📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -507,12 +515,52 @@ class DebugSettingsManager private constructor() {
|
||||
// Do not update counters here; this path is for readable logs only.
|
||||
}
|
||||
|
||||
// Explicit incoming/outgoing logging to avoid double counting
|
||||
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = fromNickname ?: fromPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
|
||||
// MARK: - Debug Events for Animation
|
||||
sealed class MeshVisualEvent {
|
||||
data class PacketActivity(val peerID: String) : MeshVisualEvent()
|
||||
data class RouteActivity(val route: List<String>) : MeshVisualEvent()
|
||||
}
|
||||
|
||||
private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow<MeshVisualEvent>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow<MeshVisualEvent> = _meshVisualEvents.asSharedFlow()
|
||||
|
||||
fun emitVisualEvent(event: MeshVisualEvent) {
|
||||
if (_debugSheetVisible.value) {
|
||||
_meshVisualEvents.tryEmit(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Peer nickname resolver
|
||||
private var nicknameResolver: ((String) -> String?)? = null
|
||||
fun setNicknameResolver(resolver: (String) -> String?) { nicknameResolver = resolver }
|
||||
|
||||
// Explicit incoming/outgoing logging to avoid double counting
|
||||
fun logIncoming(packet: BitchatPacket, fromPeerID: String, fromNickname: String?, fromDeviceAddress: String?, myPeerID: String) {
|
||||
val packetType = packet.type.toString()
|
||||
val packetVersion = packet.version
|
||||
val route = packet.route
|
||||
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
|
||||
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val resolvedNick = fromNickname ?: nicknameResolver?.invoke(fromPeerID) ?: "unknown"
|
||||
val who = if (resolvedNick != "unknown") "$resolvedNick ($fromPeerID)" else fromPeerID
|
||||
val routeStr = if (routeInfo != null) " $routeInfo" else ""
|
||||
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromDeviceAddress ?: "?"})$routeStr"))
|
||||
}
|
||||
|
||||
emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID))
|
||||
|
||||
if (!route.isNullOrEmpty()) {
|
||||
val fullRoute = mutableListOf<String>()
|
||||
fullRoute.add(packet.senderID.toHexString())
|
||||
route.forEach { fullRoute.add(it.toHexString()) }
|
||||
packet.recipientID?.let { fullRoute.add(it.toHexString()) }
|
||||
emitVisualEvent(MeshVisualEvent.RouteActivity(fullRoute))
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
if (visible) incomingTimestamps.offer(now)
|
||||
@@ -521,11 +569,11 @@ class DebugSettingsManager private constructor() {
|
||||
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
|
||||
}
|
||||
fromPeerID?.let {
|
||||
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
|
||||
}
|
||||
|
||||
perPeerIncoming.getOrPut(fromPeerID) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerIncomingTotalsMap[fromPeerID] = (peerIncomingTotalsMap[fromPeerID] ?: 0L) + 1L
|
||||
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
|
||||
|
||||
// bump totals
|
||||
val cur = _relayStats.value
|
||||
_relayStats.value = cur.copy(
|
||||
@@ -535,10 +583,11 @@ class DebugSettingsManager private constructor() {
|
||||
if (visible) updateRelayStatsFromTimestamps()
|
||||
}
|
||||
|
||||
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
|
||||
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = toNickname ?: toPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
|
||||
val routeStr = if (routeInfo != null) " $routeInfo" else ""
|
||||
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr"))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
|
||||
@@ -5,15 +5,15 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiTethering
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||
@@ -29,12 +29,65 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bitchat.android.service.MeshServicePreferences
|
||||
import com.bitchat.android.service.MeshForegroundService
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
|
||||
@Composable
|
||||
fun MeshTopologySection() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val graphService = remember { MeshGraphService.getInstance() }
|
||||
val snapshot by graphService.graphState.collectAsState()
|
||||
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
|
||||
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
val nodes = snapshot.nodes
|
||||
val edges = snapshot.edges
|
||||
val empty = nodes.isEmpty()
|
||||
if (empty) {
|
||||
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
ForceDirectedMeshGraph(
|
||||
nodes = nodes,
|
||||
edges = edges,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(colorScheme.surface.copy(alpha = 0.4f))
|
||||
)
|
||||
|
||||
// Flexible peer list
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
nodes.forEach { node ->
|
||||
val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}"
|
||||
Text(
|
||||
text = label,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
|
||||
|
||||
@@ -45,7 +98,6 @@ fun DebugSettingsSheet(
|
||||
onDismiss: () -> Unit,
|
||||
meshService: BluetoothMeshService
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val manager = remember { DebugSettingsManager.getInstance() }
|
||||
|
||||
@@ -71,6 +123,16 @@ fun DebugSettingsSheet(
|
||||
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
|
||||
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
|
||||
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||
val listState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
// Push live connected devices from mesh service whenever sheet is visible
|
||||
LaunchedEffect(isPresented) {
|
||||
@@ -112,35 +174,31 @@ fun DebugSettingsSheet(
|
||||
|
||||
if (!isPresented) return
|
||||
|
||||
ModalBottomSheet(
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
// Mark debug sheet visible/invisible to gate heavy work
|
||||
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
|
||||
Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_tools_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.debug_tools_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
// Verbose logging toggle
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
@@ -161,6 +219,11 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Mesh topology visualization (moved below verbose logging)
|
||||
item {
|
||||
MeshTopologySection()
|
||||
}
|
||||
|
||||
// GATT controls
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package com.bitchat.android.ui.debug
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import kotlin.math.*
|
||||
import kotlin.random.Random
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent
|
||||
|
||||
// Physics constants
|
||||
private const val REPULSION_FORCE = 100000f
|
||||
private const val SPRING_LENGTH = 150f
|
||||
private const val SPRING_STRENGTH = 0.02f
|
||||
private const val CENTER_GRAVITY = 0.02f
|
||||
private const val DAMPING = 0.85f
|
||||
private const val MAX_VELOCITY = 30f
|
||||
private const val PULSE_DECAY = 0.05f
|
||||
private const val ROUTE_DECAY = 0.02f
|
||||
|
||||
private class GraphNodeState(
|
||||
val id: String,
|
||||
var label: String,
|
||||
var x: Float,
|
||||
var y: Float
|
||||
) {
|
||||
var vx: Float = 0f
|
||||
var vy: Float = 0f
|
||||
var isDragged: Boolean = false
|
||||
var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation
|
||||
}
|
||||
|
||||
private class Simulation {
|
||||
val nodes = mutableMapOf<String, GraphNodeState>()
|
||||
// Storing edges as pairs of IDs
|
||||
val edges = mutableListOf<MeshGraphService.GraphEdge>()
|
||||
// Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0)
|
||||
val activeRoutes = mutableListOf<Pair<List<String>, Float>>()
|
||||
|
||||
// Bounds for initial placement and centering
|
||||
var width: Float = 1000f
|
||||
var height: Float = 1000f
|
||||
|
||||
fun updateTopology(
|
||||
newNodes: List<MeshGraphService.GraphNode>,
|
||||
newEdges: List<MeshGraphService.GraphEdge>
|
||||
) {
|
||||
// Remove stale nodes
|
||||
val newIds = newNodes.map { it.peerID }.toSet()
|
||||
nodes.keys.toList().forEach { id ->
|
||||
if (id !in newIds) nodes.remove(id)
|
||||
}
|
||||
|
||||
// Add/Update nodes
|
||||
newNodes.forEach { n ->
|
||||
val existing = nodes[n.peerID]
|
||||
val displayLabel = n.nickname ?: n.peerID.take(8)
|
||||
if (existing != null) {
|
||||
existing.label = displayLabel
|
||||
} else {
|
||||
// Spawn near center with random jitter
|
||||
val angle = Random.nextFloat() * 2 * PI
|
||||
val radius = 50f + Random.nextFloat() * 50f
|
||||
nodes[n.peerID] = GraphNodeState(
|
||||
id = n.peerID,
|
||||
label = displayLabel,
|
||||
x = (width / 2f) + (cos(angle) * radius).toFloat(),
|
||||
y = (height / 2f) + (sin(angle) * radius).toFloat()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update edges
|
||||
edges.clear()
|
||||
edges.addAll(newEdges)
|
||||
}
|
||||
|
||||
fun triggerNodePulse(peerID: String) {
|
||||
nodes[peerID]?.pulseLevel = 1f
|
||||
}
|
||||
|
||||
fun triggerRouteAnimation(route: List<String>) {
|
||||
if (route.size > 1) {
|
||||
activeRoutes.add(route to 1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun step() {
|
||||
val nodeList = nodes.values.toList()
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
|
||||
// 1. Repulsion (Node-Node)
|
||||
for (i in nodeList.indices) {
|
||||
val n1 = nodeList[i]
|
||||
for (j in i + 1 until nodeList.size) {
|
||||
val n2 = nodeList[j]
|
||||
val dx = n1.x - n2.x
|
||||
val dy = n1.y - n2.y
|
||||
val distSq = dx * dx + dy * dy
|
||||
if (distSq > 0.1f) {
|
||||
val dist = sqrt(distSq)
|
||||
val force = REPULSION_FORCE / distSq
|
||||
val fx = (dx / dist) * force
|
||||
val fy = (dy / dist) * force
|
||||
|
||||
if (!n1.isDragged) {
|
||||
n1.vx += fx
|
||||
n1.vy += fy
|
||||
}
|
||||
if (!n2.isDragged) {
|
||||
n2.vx -= fx
|
||||
n2.vy -= fy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attraction (Edges)
|
||||
edges.forEach { edge ->
|
||||
val n1 = nodes[edge.a]
|
||||
val n2 = nodes[edge.b]
|
||||
if (n1 != null && n2 != null) {
|
||||
val dx = n1.x - n2.x
|
||||
val dy = n1.y - n2.y
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
if (dist > 0.1f) {
|
||||
val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH
|
||||
val fx = (dx / dist) * force
|
||||
val fy = (dy / dist) * force
|
||||
|
||||
if (!n1.isDragged) {
|
||||
n1.vx -= fx
|
||||
n1.vy -= fy
|
||||
}
|
||||
if (!n2.isDragged) {
|
||||
n2.vx += fx
|
||||
n2.vy += fy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Center Gravity & Integration & Animation Decay
|
||||
nodeList.forEach { n ->
|
||||
if (!n.isDragged) {
|
||||
// Pull to center
|
||||
val dx = n.x - cx
|
||||
val dy = n.y - cy
|
||||
n.vx -= dx * CENTER_GRAVITY
|
||||
n.vy -= dy * CENTER_GRAVITY
|
||||
|
||||
// Apply velocity
|
||||
val vMag = sqrt(n.vx * n.vx + n.vy * n.vy)
|
||||
if (vMag > MAX_VELOCITY) {
|
||||
n.vx = (n.vx / vMag) * MAX_VELOCITY
|
||||
n.vy = (n.vy / vMag) * MAX_VELOCITY
|
||||
}
|
||||
|
||||
n.x += n.vx
|
||||
n.y += n.vy
|
||||
|
||||
// Damping
|
||||
n.vx *= DAMPING
|
||||
n.vy *= DAMPING
|
||||
} else {
|
||||
n.vx = 0f
|
||||
n.vy = 0f
|
||||
}
|
||||
|
||||
// Decay pulse
|
||||
if (n.pulseLevel > 0f) {
|
||||
n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f)
|
||||
}
|
||||
}
|
||||
|
||||
// Decay active routes
|
||||
val iter = activeRoutes.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val (route, intensity) = iter.next()
|
||||
val newIntensity = intensity - ROUTE_DECAY
|
||||
if (newIntensity <= 0f) {
|
||||
iter.remove()
|
||||
} else {
|
||||
// Ugly mutation but efficient for simulation loop
|
||||
// We need to replace the pair since pairs are immutable
|
||||
// Finding index is O(N) but N is small
|
||||
val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity }
|
||||
if (idx >= 0) {
|
||||
activeRoutes[idx] = route to newIntensity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ForceDirectedMeshGraph(
|
||||
nodes: List<MeshGraphService.GraphNode>,
|
||||
edges: List<MeshGraphService.GraphEdge>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val simulation = remember { Simulation() }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Listen for visual events
|
||||
val debugManager = remember { DebugSettingsManager.getInstance() }
|
||||
LaunchedEffect(Unit) {
|
||||
debugManager.meshVisualEvents.collect { event ->
|
||||
when (event) {
|
||||
is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID)
|
||||
is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need a state that changes on every tick to trigger redraw
|
||||
var tick by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// Update topology when input data changes
|
||||
LaunchedEffect(nodes, edges) {
|
||||
simulation.updateTopology(nodes, edges)
|
||||
}
|
||||
|
||||
// Animation Loop
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
withFrameNanos {
|
||||
simulation.step()
|
||||
tick++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val w = maxWidth.value * density.density
|
||||
val h = maxHeight.value * density.density
|
||||
|
||||
// Update simulation bounds if size changes
|
||||
SideEffect {
|
||||
simulation.width = w
|
||||
simulation.height = h
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
// Find closest node
|
||||
val closest = simulation.nodes.values.minByOrNull {
|
||||
val dx = it.x - offset.x
|
||||
val dy = it.y - offset.y
|
||||
dx*dx + dy*dy
|
||||
}
|
||||
if (closest != null) {
|
||||
val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2))
|
||||
if (dist < 80f) { // Touch radius
|
||||
closest.isDragged = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
simulation.nodes.values.forEach { it.isDragged = false }
|
||||
},
|
||||
onDragCancel = {
|
||||
simulation.nodes.values.forEach { it.isDragged = false }
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
val dragged = simulation.nodes.values.find { it.isDragged }
|
||||
if (dragged != null) {
|
||||
dragged.x += dragAmount.x
|
||||
dragged.y += dragAmount.y
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Read tick to ensure recomposition
|
||||
val t = tick
|
||||
|
||||
val nodeMap = simulation.nodes
|
||||
|
||||
// Draw Edges
|
||||
simulation.edges.forEach { edge ->
|
||||
val n1 = nodeMap[edge.a]
|
||||
val n2 = nodeMap[edge.b]
|
||||
|
||||
if (n1 != null && n2 != null) {
|
||||
val start = Offset(n1.x, n1.y)
|
||||
val end = Offset(n2.x, n2.y)
|
||||
val baseColor = Color(0xFF4A90E2)
|
||||
|
||||
if (edge.isConfirmed) {
|
||||
drawLine(
|
||||
color = baseColor,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = 5f
|
||||
)
|
||||
} else {
|
||||
// Unconfirmed: draw "solid" from declarer, "dashed" from other
|
||||
val isA = (edge.confirmedBy == edge.a)
|
||||
val solidStart = if (isA) start else end
|
||||
val solidEnd = if (isA) end else start
|
||||
|
||||
val midX = (start.x + end.x) / 2
|
||||
val midY = (start.y + end.y) / 2
|
||||
val mid = Offset(midX, midY)
|
||||
|
||||
drawLine(
|
||||
color = baseColor,
|
||||
start = solidStart,
|
||||
end = mid,
|
||||
strokeWidth = 4f
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
start = mid,
|
||||
end = solidEnd,
|
||||
strokeWidth = 4f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Active Routes (overlay)
|
||||
simulation.activeRoutes.forEach { (route, intensity) ->
|
||||
val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold
|
||||
val strokeW = 4f * intensity + 2f
|
||||
|
||||
for (i in 0 until route.size - 1) {
|
||||
val p1 = nodeMap[route[i]]
|
||||
val p2 = nodeMap[route[i+1]]
|
||||
if (p1 != null && p2 != null) {
|
||||
drawLine(
|
||||
color = routeColor,
|
||||
start = Offset(p1.x, p1.y),
|
||||
end = Offset(p2.x, p2.y),
|
||||
strokeWidth = strokeW,
|
||||
cap = androidx.compose.ui.graphics.StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Nodes
|
||||
val labelColor = colorScheme.onSurface.toArgb()
|
||||
val textPaint = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = 12.sp.toPx()
|
||||
this.color = labelColor
|
||||
}
|
||||
|
||||
nodeMap.values.forEach { node ->
|
||||
val center = Offset(node.x, node.y)
|
||||
val pulse = node.pulseLevel
|
||||
|
||||
// Pulse glow
|
||||
if (pulse > 0.05f) {
|
||||
drawCircle(
|
||||
color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f),
|
||||
radius = 16f + (pulse * 20f),
|
||||
center = center
|
||||
)
|
||||
}
|
||||
|
||||
drawCircle(
|
||||
color = Color(0xFF00C851),
|
||||
radius = 16f + (pulse * 4f), // Slight scale up
|
||||
center = center
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 12f + (pulse * 3f),
|
||||
center = center,
|
||||
style = Stroke(width = 2f)
|
||||
)
|
||||
|
||||
// Label
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
node.label,
|
||||
node.x + 22f + (pulse * 5f),
|
||||
node.y + 4f,
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -19,6 +21,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.bitchat.android.features.media.ImageUtils
|
||||
import java.io.File
|
||||
@@ -70,8 +73,16 @@ fun ImagePickerButton(
|
||||
)
|
||||
capturedImagePath = file.absolutePath
|
||||
takePictureLauncher.launch(uri)
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors; no-op
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ImagePickerButton", "Camera capture failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
startCameraCapture()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +91,13 @@ fun ImagePickerButton(
|
||||
.size(32.dp)
|
||||
.combinedClickable(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
onLongClick = { startCameraCapture() }
|
||||
onLongClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
startCameraCapture()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
||||
@@ -41,6 +41,10 @@ object AppConstants {
|
||||
const val MAX_FRAGMENT_SIZE: Int = 469
|
||||
const val FRAGMENT_TIMEOUT_MS: Long = 30_000L
|
||||
const val CLEANUP_INTERVAL_MS: Long = 10_000L
|
||||
const val MAX_FRAGMENTS_PER_ID: Int = 256
|
||||
const val MAX_FRAGMENT_TOTAL_BYTES: Int = 1_048_576
|
||||
const val MAX_ACTIVE_FRAGMENT_SETS: Int = 64
|
||||
const val MAX_GLOBAL_FRAGMENT_TOTAL_BYTES: Long = 4L * 1_048_576L
|
||||
}
|
||||
|
||||
object Security {
|
||||
@@ -58,8 +62,13 @@ object AppConstants {
|
||||
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
|
||||
}
|
||||
|
||||
object Verification {
|
||||
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
|
||||
}
|
||||
|
||||
object Protocol {
|
||||
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
|
||||
const val MAX_PAYLOAD_LENGTH: Int = 10_485_760
|
||||
}
|
||||
|
||||
object StoreForward {
|
||||
@@ -76,9 +85,9 @@ object AppConstants {
|
||||
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
|
||||
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
|
||||
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
|
||||
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L
|
||||
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L
|
||||
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
|
||||
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
|
||||
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
|
||||
const val MAX_CONNECTIONS_NORMAL: Int = 8
|
||||
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
|
||||
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -364,4 +364,40 @@
|
||||
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
|
||||
<string name="bluetooth_recommended">بلوتوث موصى به</string>
|
||||
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
<string name="verify_my_qr_title">scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
|
||||
<string name="verify_scan_someone">scan someone elses qr</string>
|
||||
<string name="verify_show_my_qr">show my qr</string>
|
||||
<string name="verify_remove">remove verification</string>
|
||||
<string name="verify_qr_unavailable">qr unavailable</string>
|
||||
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
|
||||
<string name="verify_request_camera">enable camera</string>
|
||||
<string name="verify_paste_label">paste verification url</string>
|
||||
<string name="verify_validate">validate</string>
|
||||
<string name="verify_scanned">verification requested</string>
|
||||
<string name="security_verification_title">security verification</string>
|
||||
<string name="fingerprint_their">their fingerprint</string>
|
||||
<string name="fingerprint_yours">your fingerprint</string>
|
||||
<string name="fingerprint_pending">handshake pending</string>
|
||||
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
|
||||
<string name="fingerprint_status_verified">encrypted & verified</string>
|
||||
<string name="fingerprint_status_encrypted">encrypted</string>
|
||||
<string name="fingerprint_status_handshaking">handshaking</string>
|
||||
<string name="fingerprint_status_failed">handshake failed</string>
|
||||
<string name="fingerprint_status_uninitialized">not encrypted</string>
|
||||
<string name="fingerprint_verified_label">verified</string>
|
||||
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
|
||||
<string name="fingerprint_not_verified_label">not verified</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
|
||||
<string name="fingerprint_mark_verified">mark as verified</string>
|
||||
<string name="fingerprint_start_handshake">start handshake</string>
|
||||
<string name="fingerprint_copy">copy</string>
|
||||
<string name="verify_mutual_match_title">Mutual verification</string>
|
||||
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
|
||||
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -351,4 +351,40 @@
|
||||
|
||||
<string name="bluetooth_recommended">ব্লুটুথ প্রস্তাবিত</string>
|
||||
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
<string name="verify_my_qr_title">scan to verify me</string>
|
||||
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
|
||||
<string name="verify_scan_someone">scan someone elses qr</string>
|
||||
<string name="verify_show_my_qr">show my qr</string>
|
||||
<string name="verify_remove">remove verification</string>
|
||||
<string name="verify_qr_unavailable">qr unavailable</string>
|
||||
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
|
||||
<string name="verify_request_camera">enable camera</string>
|
||||
<string name="verify_paste_label">paste verification url</string>
|
||||
<string name="verify_validate">validate</string>
|
||||
<string name="verify_scanned">verification requested</string>
|
||||
<string name="security_verification_title">security verification</string>
|
||||
<string name="fingerprint_their">their fingerprint</string>
|
||||
<string name="fingerprint_yours">your fingerprint</string>
|
||||
<string name="fingerprint_pending">handshake pending</string>
|
||||
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
|
||||
<string name="fingerprint_status_verified">encrypted & verified</string>
|
||||
<string name="fingerprint_status_encrypted">encrypted</string>
|
||||
<string name="fingerprint_status_handshaking">handshaking</string>
|
||||
<string name="fingerprint_status_failed">handshake failed</string>
|
||||
<string name="fingerprint_status_uninitialized">not encrypted</string>
|
||||
<string name="fingerprint_verified_label">verified</string>
|
||||
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
|
||||
<string name="fingerprint_not_verified_label">not verified</string>
|
||||
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
|
||||
<string name="fingerprint_mark_verified">mark as verified</string>
|
||||
<string name="fingerprint_start_handshake">start handshake</string>
|
||||
<string name="fingerprint_copy">copy</string>
|
||||
<string name="verify_mutual_match_title">Mutual verification</string>
|
||||
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
|
||||
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user