Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media.
and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in
`tools/`.

`app/` is the source of truth for shared mesh/protocol code.
`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list
in `wear/build.gradle.kts`. Extend that list; never copy shared
Kotlin into `wear/src/` or edit generated `build/` content.
`app/` is the source of truth for shared mesh/protocol code, alongside
`core/domain/`, which holds the domain models that Android library modules
need (Gradle forbids a library module depending on an application module).
`syncSharedAppSources` generates `wear/build/sharedSrc` from both trees using
the include list in `wear/build.gradle.kts`. Extend that list; never copy
shared Kotlin into `wear/src/` or edit generated `build/` content.

`build-logic/` holds the Gradle convention plugins (`bitchat.android.*`)
applied by library modules. It is an included build; it does not appear in
`settings.gradle.kts` as a project.

## Build, Test & Development Commands

Expand Down
13 changes: 12 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}

val githubReleaseCertSha256 = providers
Expand Down Expand Up @@ -126,6 +128,10 @@ kotlin {
}

dependencies {
// Shared domain models, also compiled into :wear from source
implementation(project(":core:domain"))
implementation(project(":core:navigation"))

// Core Android dependencies
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
Expand All @@ -141,7 +147,12 @@ dependencies {

// Navigation
implementation(libs.androidx.navigation.compose)


// Dependency injection
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.hilt.compiler)

// Permissions
implementation(libs.accompanist.permissions)

Expand Down
59 changes: 45 additions & 14 deletions app/gradle.lockfile

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions app/src/main/java/com/bitchat/android/BitchatApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.ArtiTorManager
import dagger.hilt.android.HiltAndroidApp

/**
* Main application class for bitchat Android
*/
@HiltAndroidApp
class BitchatApplication : Application() {

override fun onCreate() {
Expand Down
92 changes: 60 additions & 32 deletions app/src/main/java/com/bitchat/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
Expand Down Expand Up @@ -45,10 +44,19 @@ import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.navigation.AppNavigator
import com.bitchat.android.navigation.BitchatNavDisplay
import com.bitchat.android.navigation.ChatRoute
import com.bitchat.android.navigation.EntryProviderInstaller
import com.bitchat.android.navigation.OnboardingRoute
import com.bitchat.android.navigation.rootRouteFor
import com.bitchat.android.services.VerificationService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@AndroidEntryPoint
class MainActivity : OrientationAwareActivity() {

private lateinit var permissionManager: PermissionManager
Expand All @@ -62,15 +70,16 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private var pendingMeshForegroundServiceStart = false
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}

private val chatViewModel: ChatViewModel by viewModels()

// Held by ActivityRetainedComponent, so the back stack outlives configuration
// changes without being rebuilt here.
@Inject
lateinit var navigator: AppNavigator

@Inject
lateinit var locationChannelManager: LocationChannelManager

private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
Expand Down Expand Up @@ -154,15 +163,49 @@ class MainActivity : OrientationAwareActivity() {
onOnboardingFailed = ::handleOnboardingFailed
)

// Seed the stack before the first composition. NavDisplay rejects an empty
// back stack, and a LaunchedEffect would not run until after it has already
// composed once.
navigator.setRootIfEmpty(rootRouteFor(mainViewModel.onboardingState.value))

setContent {
BitchatTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
OnboardingFlowScreen(modifier = Modifier
val contentModifier = Modifier
.fillMaxSize()
.padding(innerPadding)
val onboardingState by mainViewModel.onboardingState.collectAsState()
val root = rootRouteFor(onboardingState)

// Keyed on root, so this fires only when the app crosses between
// onboarding and chat — not on every step within onboarding.
// resetTo rather than goTo: onboarding must not be reachable with
// Back once the app is in.
LaunchedEffect(root) {
if (navigator.backStack.lastOrNull() != root) {
navigator.resetTo(root)
}
}

val entries: EntryProviderInstaller = {
entry<OnboardingRoute> { OnboardingFlowScreen(contentModifier) }
entry<ChatRoute> { ChatScreen(viewModel = chatViewModel) }
}

BitchatNavDisplay(
navigator = navigator,
entryInstallers = setOf(entries),
onExit = { finish() },
modifier = contentModifier,
// Chat still drives its overlays with booleans, so Back has to
// ask it first. Removed once those overlays become routes.
interceptBack = {
navigator.backStack.lastOrNull() == ChatRoute &&
chatViewModel.handleBackPressed()
},
)
}
}
Expand Down Expand Up @@ -312,27 +355,12 @@ class MainActivity : OrientationAwareActivity() {
)
}

OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Let ChatViewModel handle navigation state
val handled = chatViewModel.handleBackPressed()
if (!handled) {
// If ChatViewModel doesn't handle it, disable this callback
// and let the system handle it (which will exit the app)
this.isEnabled = false
onBackPressedDispatcher.onBackPressed()
this.isEnabled = true
}
}
}
// CHECKING, INITIALIZING and COMPLETE are handled by ChatRoute, so this
// composable is only ever shown for the onboarding steps themselves.
OnboardingState.CHECKING,
OnboardingState.INITIALIZING,
OnboardingState.COMPLETE -> Unit

// Add the callback - this will be automatically removed when the activity is destroyed
onBackPressedDispatcher.addCallback(this, backCallback)
ChatScreen(viewModel = chatViewModel)
}

OnboardingState.ERROR -> {
InitializationErrorScreen(
modifier = modifier,
Expand Down Expand Up @@ -747,7 +775,7 @@ class MainActivity : OrientationAwareActivity() {
override fun onResume() {
super.onResume()
// Revoke stale live-location work before any resumed UI can use cached channels.
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
locationChannelManager.syncPermissionState()

// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/com/bitchat/android/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.LocationStatus
import com.bitchat.android.onboarding.OnboardingState
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject

class MainViewModel : ViewModel() {
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {

private val _onboardingState = MutableStateFlow(OnboardingState.CHECKING)
val onboardingState: StateFlow<OnboardingState> = _onboardingState.asStateFlow()
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/com/bitchat/android/di/GeohashModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.bitchat.android.di

import android.content.Context
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.geohash.LocationChannelManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* Bridges the geohash singletons into the graph.
*
* Both hold location state and are process-wide today. Injecting them as
* dagger.Lazy at the call site preserves the current behaviour of not touching
* them until something actually needs a channel or a bookmark.
*/
@Module
@InstallIn(SingletonComponent::class)
object GeohashModule {

@Provides
@Singleton
fun provideLocationChannelManager(
@ApplicationContext context: Context
): LocationChannelManager = LocationChannelManager.getInstance(context)

@Provides
@Singleton
fun provideGeohashBookmarksStore(
@ApplicationContext context: Context
): GeohashBookmarksStore = GeohashBookmarksStore.getInstance(context)
}
36 changes: 36 additions & 0 deletions app/src/main/java/com/bitchat/android/di/MeshModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.bitchat.android.di

import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.service.MeshServiceHolder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent

/**
* Bridges the process-wide mesh instances into the injection graph.
*
* [MeshServiceHolder] remains the single source of truth: it is shared with the
* foreground service, it synchronises creation, and it supports replacing the
* mesh service after a panic clear. These bindings are therefore deliberately
* unscoped — each injection re-reads the holder. Caching them with @Singleton
* would pin the first instance for the process lifetime and hand out a stale
* mesh service after a replacement.
*/
@Module
@InstallIn(SingletonComponent::class)
object MeshModule {

@Provides
fun provideBluetoothMeshService(
@ApplicationContext context: Context
): BluetoothMeshService = MeshServiceHolder.getOrCreate(context)

@Provides
fun provideMeshService(
@ApplicationContext context: Context
): MeshService = MeshServiceHolder.getUnifiedOrCreate(context)
}
27 changes: 27 additions & 0 deletions app/src/main/java/com/bitchat/android/di/NostrModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.bitchat.android.di

import android.content.Context
import com.bitchat.android.nostr.NostrTransport
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* Bridges the Nostr transport into the graph.
*
* Inject it as dagger.Lazy. Resolving it eagerly would construct the transport
* during ViewModel creation, which is earlier than the current call sites do it.
*/
@Module
@InstallIn(SingletonComponent::class)
object NostrModule {

@Provides
@Singleton
fun provideNostrTransport(
@ApplicationContext context: Context
): NostrTransport = NostrTransport.getInstance(context)
}
Loading
Loading