feat(di): inject the phone client's ViewModels with Hilt

Replaces the hand-rolled ViewModelProvider.Factory in MainActivity with
constructor injection. No singleton is converted and no behaviour changes;
this only moves ViewModel construction into the graph so feature modules
can obtain their own ViewModels later.

MeshModule bridges the existing process-wide mesh instances into the graph.
Its bindings are deliberately unscoped: MeshServiceHolder is the source of
truth, it is shared with the foreground service, and it supports replacing
the mesh service after a panic clear. A @Singleton binding would pin the
first instance for the process lifetime and hand out a stale service after
a replacement.

ChatViewModel still resolves to a single activity-scoped instance, so the
unifiedMeshService.delegate assignment continues to reference the same
object it did before.

ApkDownloadViewModel is injected through its secondary constructor. Its
collaborators are internal types, and routing those through Dagger's
generated Java buys nothing; the primary constructor stays available so
tests can keep substituting fakes.

Verified: :app:assembleDebug, :wear:assembleDebug, and 591 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaZivRfLu7FdKjzVwwRjcc
(cherry picked from commit 33d6b36e220810c87595851ece7e9a63535c61b0)
This commit is contained in:
Moe Hamade 2026-08-15 01:35:16 +03:00
parent 8b7af243b3
commit bf782f9072
9 changed files with 68 additions and 15 deletions

View File

@ -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() {

View File

@ -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
@ -46,9 +45,11 @@ import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : OrientationAwareActivity() {
private lateinit var permissionManager: PermissionManager
@ -62,15 +63,8 @@ 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()
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) {

View File

@ -7,9 +7,12 @@ import com.bitchat.android.onboarding.OnboardingState
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import dagger.hilt.android.lifecycle.HiltViewModel
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()

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -47,6 +48,7 @@ import java.io.File
* Activity for managing Wi-Fi P2P hotspot for offline APK sharing.
* Pure Compose implementation, no fragments.
*/
@AndroidEntryPoint
class HotspotActivity : ComponentActivity() {
companion object {

View File

@ -7,6 +7,8 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.wifiaware.WifiAwareController
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -16,7 +18,10 @@ import java.io.File
/**
* ViewModel for managing hotspot state and lifecycle.
*/
class HotspotViewModel(application: Application) : AndroidViewModel(application) {
@HiltViewModel
class HotspotViewModel @Inject constructor(
application: Application
) : AndroidViewModel(application) {
companion object {
private const val TAG = "HotspotViewModel"

View File

@ -63,8 +63,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
@ -617,7 +617,7 @@ fun AboutSheet(
)
// === Prepare App for Sharing Section ===
val apkViewModel: ApkDownloadViewModel = viewModel()
val apkViewModel: ApkDownloadViewModel = hiltViewModel()
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
val apkStatus = apkUiState.apkStatus
val releaseStatus = apkUiState.releaseStatus

View File

@ -15,6 +15,8 @@ import com.bitchat.android.util.LatestReleaseProvider
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
@ -151,6 +153,7 @@ sealed class ApkUiEffect {
* ViewModel for APK download/status/share logic following MVI pattern.
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
*/
@HiltViewModel
class ApkDownloadViewModel internal constructor(
application: Application,
private val apkManager: UniversalApkManager,
@ -158,6 +161,11 @@ class ApkDownloadViewModel internal constructor(
private val latestReleaseProvider: LatestReleaseProvider
) : AndroidViewModel(application) {
// Hilt injects through this constructor rather than the primary one:
// UniversalApkManager's collaborators are internal, and routing internal
// Kotlin types through Dagger's generated Java buys nothing here. Tests
// keep using the primary constructor to substitute fakes.
@Inject
constructor(application: Application) : this(
application = application,
apkManager = UniversalApkManager(application),

View File

@ -7,6 +7,8 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesChangeListener
import com.bitchat.android.favorites.FavoritesPersistenceService
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -53,7 +55,8 @@ private data class ConversationLiveIdentityState(
* Refactored ChatViewModel - Main coordinator for bitchat functionality
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
*/
class ChatViewModel(
@HiltViewModel
class ChatViewModel @Inject constructor(
application: Application,
initialMeshService: BluetoothMeshService,
initialUnifiedMeshService: MeshService