diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index a5a31ce6..2f8a7906 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -7,9 +7,10 @@ on:
branches: [ "main", "develop" ]
jobs:
- test:
+ verify:
+ name: Test & Lint
runs-on: ubuntu-latest
-
+
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -39,7 +40,10 @@ jobs:
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- - name: Upload Test Reports (xml+html)
+ - name: Run lint
+ run: ./gradlew lintDebug
+
+ - name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
@@ -48,47 +52,6 @@ jobs:
**/build/test-results/
**/build/reports/tests/
- - name: Upload test results
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: test-results
- path: |
- **/build/test-results/
- **/build/reports/tests/
-
- lint:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Set up JDK 17
- uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: 'temurin'
-
- - name: Setup Gradle
- uses: gradle/gradle-build-action@v3
-
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
-
- - name: Cache Gradle packages
- uses: actions/cache@v3
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: Run lint
- run: ./gradlew lintDebug
-
- name: Upload lint results
uses: actions/upload-artifact@v4
if: always()
@@ -97,9 +60,13 @@ jobs:
path: '**/build/reports/lint-results-*.html'
build:
+ name: Build ${{ matrix.variant }}
runs-on: ubuntu-latest
- needs: [test, lint]
-
+ needs: verify
+ strategy:
+ matrix:
+ variant: [Debug, Release]
+
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -126,20 +93,11 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- - name: Build debug APK
- run: ./gradlew assembleDebug
+ - name: Build ${{ matrix.variant }} APK
+ run: ./gradlew assemble${{ matrix.variant }}
- - name: Build release APK
- run: ./gradlew assembleRelease
-
- - name: Upload debug APK
+ - name: Upload ${{ matrix.variant }} APK
uses: actions/upload-artifact@v4
with:
- name: debug-apk
- path: app/build/outputs/apk/debug/*.apk
-
- - name: Upload release APK
- uses: actions/upload-artifact@v4
- with:
- name: release-apk
- path: app/build/outputs/apk/release/*.apk
+ name: ${{ matrix.variant }}-apk
+ path: app/build/outputs/apk/**/*.apk
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4b3b8b7b..79495ee9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
with:
distribution: temurin
java-version: 17
-
+
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
@@ -38,30 +38,27 @@ jobs:
- name: Grant execute permission for Gradlew
run: chmod +x ./gradlew
- - name: Build APK
+ - name: Build Release APK (with Tor)
run: ./gradlew assembleRelease --no-daemon --stacktrace
-
+
- name: List APK files
run: |
echo "APK files built:"
- find app/build/outputs/apk/release -name "*.apk" -type f
+ find app/build/outputs/apk -name "*.apk" -type f
- name: Rename APK
run: |
- mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
-
+ mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk
+
- name: DEBUG
run: |
set -x
-
pwd
-
ls -all
-
cd app/build/outputs/
ls -all
- tree
-
+ tree || ls -R
+
# Optional: Sign APK (requires secrets)
# - name: Sign APK
# uses: r0adkll/sign-android-release@v1
@@ -75,7 +72,7 @@ jobs:
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with:
- name: bitchat-release-apk-${{ github.ref_name }}
+ name: bitchat-android-apk-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
retention-days: 30
if-no-files-found: error
@@ -88,13 +85,22 @@ jobs:
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
- name: bitchat-release-apk-${{ github.ref_name }}
- path: .
+ name: bitchat-android-apk-${{ github.ref_name }}
+ path: release
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
- files: bitchat.apk
+ files: |
+ release/bitchat-android.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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 151e1b4f..64ac199e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
# Other
*.log
.cxx/
-*build/
+# Gradle/Android build directories (but not tools/arti-build/)
+**/build/
+!tools/arti-build/
out/
gen/
*~
@@ -57,3 +59,7 @@ google-services.json
# Keystore files
*.jks
*.keystore
+
+# Arti build artifacts (cloned repo and Rust build cache)
+tools/arti-build/.arti-source/
+tools/arti-build/target/
diff --git a/README.md b/README.md
index 50f0cf54..c9b0091d 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android
+- **Network**: Expand your mesh through public internet relays
- **Notifications**: Message alerts and background updates
### Hardware Requirements
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ca87d8dc..f6de2444 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
- versionCode = 26
- versionName = "1.5.1"
+ versionCode = 27
+ versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -30,6 +30,12 @@ android {
}
buildTypes {
+ debug {
+ ndk {
+ // Include x86_64 for emulator support during development
+ abiFilters += listOf("arm64-v8a", "x86_64")
+ }
+ }
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -37,6 +43,11 @@ 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")
+ }
}
}
compileOptions {
@@ -95,8 +106,11 @@ dependencies {
// WebSocket
implementation(libs.okhttp)
- // Arti (Tor in Rust) Android bridge - use published AAR with native libs
- implementation("info.guardianproject:arti-mobile-ex:1.2.3")
+ // Arti (Tor in Rust) Android bridge - custom build from latest source
+ // Built with rustls, 16KB page size support, and onio//un service client
+ // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
+ // Only included in tor flavor to reduce APK size for standard builds
+ // Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
// Google Play Services Location
implementation(libs.gms.location)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index f8b6946c..d3e004db 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -17,9 +17,12 @@
-keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; }
-# Arti (Tor) ProGuard rules
+# Keep Tor implementation (always included)
+-keep class com.bitchat.android.net.RealTorProvider { *; }
+
+# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; }
--keep class org.torproject.jni.** { *; }
--keepnames class org.torproject.jni.**
+-keep class org.torproject.arti.** { *; }
+-keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.**
--dontwarn org.torproject.jni.**
+-dontwarn org.torproject.arti.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8e953323..b228fd34 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,13 @@
+
+
+
+
+
+
+
@@ -77,5 +84,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index df9cd6e5..b120b216 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
-import com.bitchat.android.net.TorManager
+import com.bitchat.android.net.ArtiTorManager
/**
* Main application class for bitchat Android
*/
class BitchatApplication : Application() {
-
+
override fun onCreate() {
super.onCreate()
-
+
// Initialize Tor first so any early network goes over Tor
- try { TorManager.init(this) } catch (_: Exception) { }
+ try {
+ val torProvider = ArtiTorManager.getInstance()
+ torProvider.init(this)
+ } catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
@@ -38,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
+ // Initialize mesh service preferences
+ try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
+
+ // Proactively start the foreground service to keep mesh alive
+ try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
+
// TorManager already initialized above
}
}
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 28d67280..6e9fbd39 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
- // Core mesh service - managed at app level
+ // Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
@@ -66,13 +66,21 @@ class MainActivity : OrientationAwareActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
+ finish()
+ return
+ }
+
// Enable edge-to-edge display for modern Android look
enableEdgeToEdge()
// Initialize permission management
permissionManager = PermissionManager(this)
- // Initialize core mesh service first
- meshService = BluetoothMeshService(this)
+ // Ensure foreground service is running and get mesh instance from holder
+ try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
+ meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@@ -630,6 +638,15 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
+ setIntent(intent)
+
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received, finishing activity")
+ finish()
+ return
+ }
+
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
@@ -640,6 +657,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
+ // Reattach mesh delegate to new ChatViewModel instance after Activity recreation
+ try { meshService.delegate = chatViewModel } catch (_: Exception) { }
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
@@ -665,13 +684,15 @@ class MainActivity : OrientationAwareActivity() {
}
}
- override fun onPause() {
+ override fun onPause() {
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) { }
}
}
@@ -746,14 +767,6 @@ class MainActivity : OrientationAwareActivity() {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}
- // Stop mesh services if app was fully initialized
- if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
- try {
- meshService.stopServices()
- Log.d("MainActivity", "Mesh services stopped successfully")
- } catch (e: Exception) {
- Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
- }
- }
+ // Do not stop mesh here; ForegroundService owns lifecycle for background reliability
}
}
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
new file mode 100644
index 00000000..96f408bc
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
@@ -0,0 +1,34 @@
+package com.bitchat.android.core.ui.component.button
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CloseButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier.Companion
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = modifier
+ .size(32.dp),
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
+ containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ modifier = Modifier.Companion.size(18.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
index b498dd83..d4ccb9bf 100644
--- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.util.Log
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf()
- private val _bookmarks = MutableLiveData>(emptyList())
- val bookmarks: LiveData> = _bookmarks
+ private val _bookmarks = MutableStateFlow>(emptyList())
+ val bookmarks: StateFlow> = _bookmarks.asStateFlow()
- private val _bookmarkNames = MutableLiveData