Skip to content
Open
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
2 changes: 2 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src/main/java/com/greybox/projectmesh/GlobalApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ class GlobalApp : Application(), DIAware {
bind<SharedPreferences>(tag = "settings") with singleton {
applicationContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
}

bind<SharedPreferences>(tag = "mesh") with singleton {
applicationContext.getSharedPreferences("project_mesh_prefs", Context.MODE_PRIVATE)
}
bind<UserRepository>() with singleton {
UserRepository(instance<MeshDatabase>().userDao())
}
Expand Down
101 changes: 47 additions & 54 deletions app/src/main/java/com/greybox/projectmesh/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.greybox.projectmesh
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
Expand Down Expand Up @@ -90,6 +89,9 @@ import kotlinx.coroutines.launch
import com.greybox.projectmesh.messaging.data.entities.Conversation
import com.greybox.projectmesh.messaging.ui.viewmodels.ChatScreenViewModel
import com.greybox.projectmesh.views.LogScreen
import com.greybox.projectmesh.mainscreen.MainViewModel
import com.greybox.projectmesh.mainscreen.MeshUiState
import com.greybox.projectmesh.mainscreen.MeshUiEvent


import com.greybox.projectmesh.views.RequestPermissionsScreen
Expand All @@ -102,7 +104,6 @@ class MainActivity : ComponentActivity(), DIAware {
super.onCreate(savedInstanceState)
// crash screen
CrashHandler.init(applicationContext, CrashScreenActivity::class.java)
val settingPref: SharedPreferences by di.instance(tag = "settings")
val appServer: AppServer by di.instance()
// Run this task asynchronously (default directory creation)
lifecycleScope.launch(Dispatchers.IO) {
Expand All @@ -112,47 +113,38 @@ class MainActivity : ComponentActivity(), DIAware {
TestDeviceService.initialize()
Log.d("MainActivity", "Test device initialized")
setContent {
val meshPrefs = getSharedPreferences("project_mesh_prefs", MODE_PRIVATE)
var hasRunBefore by rememberSaveable {
mutableStateOf(meshPrefs.getBoolean("hasRunBefore", false))
}
// Check if the app was launched from a notification
val launchedFromNotification = intent?.getBooleanExtra("from_notification", false) ?: false
// Request all permission in order
RequestPermissionsScreen(skipPermissions = launchedFromNotification)
var appTheme by remember {
mutableStateOf(AppTheme.valueOf(
settingPref.getString("app_theme", AppTheme.SYSTEM.name) ?:
AppTheme.SYSTEM.name))
}
var languageCode by remember {
mutableStateOf(settingPref.getString(
"language", "en") ?: "en")
}
var restartServerKey by remember {mutableStateOf(0)}
var deviceName by remember {
mutableStateOf(settingPref.getString("device_name", Build.MODEL) ?: Build.MODEL)
}

var autoFinish by remember {
mutableStateOf(settingPref.getBoolean("auto_finish", false))
}

var saveToFolder by remember {
mutableStateOf(
settingPref.getString("save_to_folder", null)
?: "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/Project Mesh"
// Get MainViewModel (this holds all screen state)
val mainViewModel: MainViewModel = viewModel(
factory = ViewModelFactory(
di = localDI(),
owner = LocalSavedStateRegistryOwner.current,
defaultArgs = null,
vmFactory = { di, _ -> MainViewModel(di) }
)
}
)

// State to trigger recomposition when locale changes
// Observe state from ViewModel
val uiState by mainViewModel.uiState.collectAsState()

// Use state from ViewModel instead of local state
val hasRunBefore = uiState.hasRunBefore
val currentScreen = uiState.currentScreen
val deviceName = uiState.deviceName
val appTheme = uiState.appTheme
val languageCode = uiState.languageCode
val autoFinish = uiState.autoFinish
val saveToFolder = uiState.saveToFolder
val launchedFromNotification =
intent?.getStringExtra("navigateTo") == BottomNavItem.Receive.route ||
intent?.action == "OPEN_CHAT_SCREEN" ||
intent?.action == "OPEN_CHAT_CONVERSATION"

var restartServerKey by remember { mutableStateOf(0) }
var localeState by rememberSaveable { mutableStateOf(Locale.getDefault()) }

// Remember the current screen across recompositions
var currentScreen by rememberSaveable { mutableStateOf(BottomNavItem.Home.route) }
LaunchedEffect(intent?.getStringExtra("navigateTo")) {
if (intent?.getStringExtra("navigateTo") == BottomNavItem.Receive.route) {
currentScreen = BottomNavItem.Receive.route
if (intent?.getStringExtra("navigateTo") == BottomNavItem.Receive.route){
mainViewModel.onEvent(MeshUiEvent.NavigateToScreen(BottomNavItem.Receive.route)) // ✅ CORRECT
}
}

Expand All @@ -166,19 +158,19 @@ class MainActivity : ComponentActivity(), DIAware {
InetAddress.getByName(ip)
// If that succeeds, navigate to chat screen with this IP
val route = "chatScreen/$ip"
currentScreen = route
mainViewModel.onEvent(MeshUiEvent.NavigateToScreen(route))
} catch (e: Exception) {
Log.e("MainActivity", "Invalid IP address in intent: $ip", e)
// Fall back to home screen
currentScreen = BottomNavItem.Home.route
mainViewModel.onEvent(MeshUiEvent.NavigateToScreen(BottomNavItem.Home.route))
}
}
} else if (action == "OPEN_CHAT_CONVERSATION") {
val conversationId = intent.getStringExtra("conversationId")
if (conversationId != null) {
// Navigate to chat screen with this conversation ID
val route = "chatScreen/$conversationId"
currentScreen = route
mainViewModel.onEvent(MeshUiEvent.NavigateToScreen(route))
}
}

Expand All @@ -193,26 +185,28 @@ class MainActivity : ComponentActivity(), DIAware {
localeState = updateLocale(languageCode)
}
key(localeState) {
ProjectMeshTheme(appTheme = appTheme) {
ProjectMeshTheme(appTheme = AppTheme.valueOf(appTheme)) {
RequestPermissionsScreen(skipPermissions = launchedFromNotification)

if (!hasRunBefore) {
OnboardingScreen(
onComplete = {meshPrefs.edit().putBoolean("hasRunBefore", true).apply()
hasRunBefore = true }
onComplete = {mainViewModel.onEvent(MeshUiEvent.CompleteOnboarding)
}
)
}
else{
BottomNavApp(
di,
startDestination = currentScreen,
onThemeChange = { selectedTheme -> appTheme = selectedTheme},
onLanguageChange = { selectedLanguage -> languageCode = selectedLanguage},
onThemeChange = { selectedTheme -> mainViewModel.onEvent(MeshUiEvent.UpdateTheme(selectedTheme.name)) },
onLanguageChange = { selectedLanguage -> mainViewModel.onEvent(MeshUiEvent.UpdateLanguage(selectedLanguage)) },
onNavigateToScreen = {screen ->
currentScreen = screen },
mainViewModel.onEvent(MeshUiEvent.NavigateToScreen(screen)) },
onRestartServer = {restartServerKey++},
onDeviceNameChange = {deviceName = it},
onDeviceNameChange = {newName -> mainViewModel.onEvent(MeshUiEvent.UpdateDeviceName(newName)) },
deviceName = deviceName,
onAutoFinishChange = {autoFinish = it},
onSaveToFolderChange = {saveToFolder = it}
onAutoFinishChange = { autoFinishValue -> mainViewModel.onEvent(MeshUiEvent.UpdateAutoFinish(autoFinishValue)) },
onSaveToFolderChange = { saveToFolder -> mainViewModel.onEvent(MeshUiEvent.UpdateSaveToFolder(saveToFolder)) }
)
}
}
Expand Down Expand Up @@ -486,8 +480,8 @@ fun BottomNavApp(di: DI,

@Composable
fun ConversationChatScreen (
conversationId: String,
onBackClick: () -> Unit
conversationId: String,
onBackClick: () -> Unit
){
Log.d("ConversationChatScreen", "Starting to load conversation: $conversationId")

Expand Down Expand Up @@ -644,5 +638,4 @@ fun ConversationChatScreen (
)
)
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import org.kodein.di.DI


// This is a custom factory class for creating ViewModels that can be injected with DI
class ViewModelFactory<T: ViewModel>(
private val di: DI,
Expand Down
178 changes: 178 additions & 0 deletions app/src/main/java/com/greybox/projectmesh/mainscreen/MainViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package com.greybox.projectmesh.mainscreen

import android.content.SharedPreferences
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.kodein.di.DI
import org.kodein.di.instance

class MainViewModel(
di: DI
) : ViewModel() {

// Inject dependencies
private val settingPrefs: SharedPreferences by di.instance(tag = "settings")
private val meshPrefs: SharedPreferences by di.instance(tag = "mesh")
private val node: AndroidVirtualNode by di.instance()

// Private mutable state
private val _uiState = MutableStateFlow(
MeshUiState(
deviceName = settingPrefs.getString("device_name", Build.MODEL) ?: Build.MODEL,
appTheme = settingPrefs.getString("app_theme", "SYSTEM") ?: "SYSTEM",
languageCode = settingPrefs.getString("language", "en") ?: "en",
autoFinish = settingPrefs.getBoolean("auto_finish", false),
saveToFolder = settingPrefs.getString("save_to_folder", null)
?: "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/Project Mesh",
hasRunBefore = meshPrefs.getBoolean("hasRunBefore", false)
)
)

// Public immutable state
val uiState: StateFlow<MeshUiState> = _uiState.asStateFlow()

init {
Log.d(TAG, "MainViewModel initialized")
observeMeshNetwork()
}

/**
* Observe mesh network state changes
*/
private fun observeMeshNetwork() {
viewModelScope.launch {
node.state.collect { nodeState ->
_uiState.update { state ->
state.copy(
wifiState = nodeState.wifiState,
localAddress = nodeState.address,
nodesOnMesh = nodeState.originatorMessages.keys,
connectedNodesCount = nodeState.originatorMessages.size,
isNetworkActive = nodeState.wifiState.hotspotIsStarted,
statusMessage = if (nodeState.wifiState.hotspotIsStarted) {
"Mesh active - ${nodeState.originatorMessages.size} nodes"
} else {
"Mesh network inactive"
}
)
}
}
}
}

/**
* Handle UI events
*/
fun onEvent(event: MeshUiEvent) {
when (event) {
is MeshUiEvent.StartMeshNetwork -> startMeshNetwork()
is MeshUiEvent.StopMeshNetwork -> stopMeshNetwork()
is MeshUiEvent.NavigateToScreen -> navigateToScreen(event.route)
is MeshUiEvent.NavigateToChat -> navigateToChat(event.identifier)
is MeshUiEvent.UpdateDeviceName -> updateDeviceName(event.name)
is MeshUiEvent.UpdateTheme -> updateTheme(event.theme)
is MeshUiEvent.UpdateLanguage -> updateLanguage(event.code)
is MeshUiEvent.PermissionsGranted -> handlePermissions(event.granted)
is MeshUiEvent.ClearError -> clearError()
is MeshUiEvent.CompleteOnboarding -> completeOnboarding()
}
}

private fun startMeshNetwork() {
viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true, statusMessage = "Starting mesh...") }
// Node starting happens automatically via observeMeshNetwork
Log.d(TAG, "Mesh network start requested")
} catch (e: Exception) {
Log.e(TAG, "Failed to start mesh", e)
_uiState.update {
it.copy(
isLoading = false,
error = "Failed to start mesh: ${e.message}"
)
}
}
}
}
Comment on lines +90 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The startMeshNetwork method (and stopMeshNetwork as well) is currently misleading. It updates the UI state to indicate loading but does not contain any logic to actually start the network. The comment // Node starting happens automatically via observeMeshNetwork points to an implicit behavior that makes the code harder to understand and maintain.

If an action is required to start the network (e.g., calling a method on the node instance), it should be done within this try block. This would make the method's purpose explicit and the try-catch block would be effective in handling potential errors.

If no action is needed, consider renaming the method and the corresponding UI event to better reflect what they do (e.g., ShowStartingMeshStatus).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, these methods don’t actually start or stop the mesh node. They just update the UI to show the status, while the real mesh state is handled through observeMeshNetwork(). Since there isn’t a direct start/stop call here yet, I’ve kept the behaviour as is for this PR. I can rename these methods later to make their purpose clearer if needed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kteja03 Can you just add a comment to this function saying something about this function name being a bit misleading.


private fun stopMeshNetwork() {
viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true, statusMessage = "Stopping mesh...") }
// Node stopping happens automatically
Log.d(TAG, "Mesh network stop requested")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop mesh", e)
_uiState.update {
it.copy(
isLoading = false,
error = "Failed to stop mesh: ${e.message}"
)
}
}
}
}

private fun navigateToScreen(route: String) {
_uiState.update { it.copy(currentScreen = route) }
Log.d(TAG, "Navigated to: $route")
}

private fun navigateToChat(identifier: String) {
_uiState.update {
it.copy(
shouldNavigateToChat = identifier,
currentScreen = "chatScreen/$identifier"
)
}
Log.d(TAG, "Navigating to chat: $identifier")
}

private fun updateDeviceName(name: String) {
settingPrefs.edit().putString("device_name", name).apply()
_uiState.update { it.copy(deviceName = name) }
}

private fun updateTheme(theme: String) {
settingPrefs.edit().putString("app_theme", theme).apply()
_uiState.update { it.copy(appTheme = theme) }
}

private fun updateLanguage(code: String) {
settingPrefs.edit().putString("language", code).apply()
_uiState.update { it.copy(languageCode = code) }
}

private fun handlePermissions(granted: Boolean) {
_uiState.update {
it.copy(
hasAllPermissions = granted,
permissionsRequested = true,
error = if (!granted) "Some permissions were denied" else null
)
}
}

private fun clearError() {
_uiState.update { it.copy(error = null) }
}

private fun completeOnboarding() {
meshPrefs.edit().putBoolean("hasRunBefore", true).apply()
_uiState.update { it.copy(hasRunBefore = true, showOnboarding = false) }
}

companion object {
private const val TAG = "MainViewModel"
}
}
Loading