diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..00543e568
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+build
+coverage
+node_modules
\ No newline at end of file
diff --git a/CI_README.md b/CI_README.md
new file mode 100644
index 000000000..78ccd51bb
--- /dev/null
+++ b/CI_README.md
@@ -0,0 +1,5 @@
+The CI pipeline behaves as follows:
+
+1. run Prettier on web-related filetypes, as well as XML
+ a. Do not use prettier-plugin-kotlin, it is not maintained and errors regularly
+2. On push, run super-linter. For all possible cases, run autofix (this covers Kotlin)
diff --git a/README.md b/README.md
index ddb1d424c..9fc333137 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,29 @@
# Project Mesh
--------------------
+
+---
+
Grey-box.ca
New version by wil-mesh-rmit
-Project Mesh runs locally on an android device. Build the project then either export the APK and send to the device, or use ADB to install. The app only works on physical devices, and not the android simulator.
+Project Mesh runs locally on an android device. Build the project then either export the APK and send to the device, or use ADB to install. The app only works on physical devices, and not the android simulator.
+
+
+
+
+
+
+
# Deployment Instructions
-The Android app runs independently of an internet connection. The APK needs to be installed in each phone, then the phones are able to connect to each other from within the app.
+
+The Android app runs independently of an internet connection. The APK needs to be installed in each phone, then the phones are able to connect to each other from within the app.
+
1. Open the app
2. When prompted, allow location and nearby devices permissions
3. Wait for a QR code to appear on screen
4. On each phone, use the ‘Scan QR code’ button to scan the codes of adjacent devices
5. Messages are able to be sent to connected devices with the ‘Send’ button and ‘Message’ text box
-
Credential info: N/A
GitHub URL: https://github.com/grey-box/Project-Mesh
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c4e85f5aa..14bb4991d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -4,6 +4,7 @@ plugins {
id("kotlin-kapt")
id("com.google.devtools.ksp") version "1.9.0-1.0.13"
kotlin("plugin.serialization") version "1.9.0"
+ id("org.jetbrains.kotlinx.kover") version "0.9.3"
}
android {
@@ -30,6 +31,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ signingConfig = signingConfigs.getByName("debug")
}
debug {
@@ -91,7 +93,21 @@ dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.android)
+
testImplementation(libs.junit)
+
+ // ===============================
+ // Unit testing (JVM) deps added
+ // ===============================
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
+ testImplementation("io.mockk:mockk:1.13.12")
+ testImplementation("org.robolectric:robolectric:4.12.2")
+ testImplementation("androidx.test:core:1.6.1")
+ testImplementation("app.cash.turbine:turbine:1.1.0")
+
+
+
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -144,6 +160,12 @@ dependencies {
// optional - Test helpers
testImplementation("androidx.room:room-testing:$room_version")
+ // Unit test libs (local JVM tests in app/src/test)
+ testImplementation("io.mockk:mockk:1.13.12")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
+ testImplementation("org.robolectric:robolectric:4.12.2")
+ testImplementation("androidx.test:core:1.6.1")
+
// optional - Paging 3 Integration
implementation("androidx.room:room-paging:$room_version")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 99119f3bd..fd645f9b4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,8 @@
-
-
+
+
@@ -92,11 +94,16 @@
android:resource="@xml/filepaths"/>
-
+ android:name="com.journeyapps.barcodescanner.CaptureActivity"
+ android:screenOrientation="portrait"
+ android:stateNotNeeded="true"
+ tools:replace="android:screenOrientation"
+ />
+
\ No newline at end of file
diff --git a/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt b/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt
index 684faa5c5..b3280b083 100644
--- a/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt
+++ b/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt
@@ -8,7 +8,6 @@ import java.lang.Exception
import java.lang.Thread.UncaughtExceptionHandler
import kotlin.system.exitProcess
-
class CrashHandler(private val context: Context, private val defaultHandler: UncaughtExceptionHandler, private val activityToBeLaunched: Class<*>) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, throwable: Throwable) {
@@ -21,35 +20,44 @@ class CrashHandler(private val context: Context, private val defaultHandler: Unc
}
}
- private fun launchActivity(applicationContext: Context, activity: Class<*>, exception: Throwable)
- {
- val crashIntent = Intent(applicationContext, activity).also {
- it.putExtra("CrashData", Gson().toJson(exception))
- Timber.tag("Project Mesh Error").e(exception, "Error: ");
+ private fun launchActivity(applicationContext: Context, activity: Class<*>, exception: Throwable) {
+ val crashIntent = Intent(applicationContext, activity).apply {
+ putExtra(CRASH_DATA_KEY, Gson().toJson(exception))
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
-
- crashIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
- crashIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ Timber.tag("CrashHandler").e(exception, "launchActivity: Uncaught exception caught")
applicationContext.startActivity(crashIntent)
}
companion object {
+ private const val CRASH_DATA_KEY = "CrashData"
+
+ private data class CrashPayload(
+ val detailMessage: String? = null,
+ val message: String? = null,
+ )
+
fun init(applicationContext: Context, activityToBeLaunched: Class<*>)
{
val handler = CrashHandler(applicationContext,Thread.getDefaultUncaughtExceptionHandler() as UncaughtExceptionHandler, activityToBeLaunched)
Thread.setDefaultUncaughtExceptionHandler(handler)
}
- fun getThrowableFromIntent(intent: Intent): Throwable?
- {
+ fun getThrowableFromIntent(intent: Intent): Throwable? {
+ val crashData = intent.getStringExtra(CRASH_DATA_KEY)?.takeIf { it.isNotBlank() } ?: return null
return try {
- Gson().fromJson(intent.getStringExtra("CrashData"), Throwable::class.java)
- }
- catch (e: Exception) {
- Timber.tag("CrashHandler",).e(e,"getThrowableFromIntent: ");
- null
+ Gson().fromJson(crashData, Throwable::class.java)
+ } catch (e: Exception) {
+ Timber.tag("CrashHandler").e(e, "getThrowableFromIntent: Failed to parse as Throwable, trying payload fallback")
+ try {
+ val payload = Gson().fromJson(crashData, CrashPayload::class.java)
+ payload.detailMessage?.takeIf { it.isNotBlank() }?.let(::Throwable)
+ ?: payload.message?.takeIf { it.isNotBlank() }?.let(::Throwable)
+ } catch (e2: Exception) {
+ Timber.tag("CrashHandler").e(e2, "getThrowableFromIntent: Failed to parse as CrashPayload")
+ null
+ }
}
-
}
}
}
diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/README.md b/app/src/main/java/com/greybox/projectmesh/messaging/README.md
index 120b26d98..d8f361889 100644
--- a/app/src/main/java/com/greybox/projectmesh/messaging/README.md
+++ b/app/src/main/java/com/greybox/projectmesh/messaging/README.md
@@ -1,9 +1,11 @@
# Messaging Module Documentation
## Overview
+
The messaging module is a core component of Project Mesh that enables peer-to-peer text communications between devices on the local mesh network. It provides a structured architecture for sending, receiving, storing, and displaying messages without requiring internet connectivity.
## Package Structure
+
```
messaging/
├── data/ # Data layer (entities and DAOs)
@@ -16,11 +18,14 @@ messaging/
├── screens/ # Composable UI screens
└── viewmodels/# View state management
```
-## Core Components
-### 1. Data Models
-#### Message Entity
-```Kotlin
+## Core Components
+
+### 1. Data Models
+
+#### Message Entity
+
+```Kotlin
@Serializable
@Entity(tableName = "message")
data class Message(
@@ -32,8 +37,10 @@ data class Message(
@ColumnInfo(name= "file") val file: URI? = null
)
```
+
#### Conversation Entity
-```Kotlin
+
+```Kotlin
@Entity(tableName = "conversations")
data class Conversation(
@PrimaryKey val id: String, // Composite ID of the two users
@@ -46,44 +53,63 @@ data class Conversation(
@ColumnInfo(name = "is_online") val isOnline: Boolean = false // Online status
)
```
+
### 2. Repositories
+
#### MessageRepository
+
Manages message data operations, including retrieving and storing messages.
+
#### ConversationRepository
+
Manages conversation data, including creating and updating conversations, tracking user statuses, and managing unread messages.
+
### 3. Network Components
+
#### MessageNetworkHandler
- Handles network communication for sending and receiving messages using HTTP requests.
+
+Handles network communication for sending and receiving messages using HTTP requests.
+
### 4. UI Components
+
#### ChatScreen
- Displays messages in a conversation and provides UI controls for sending new messages.
+
+Displays messages in a conversation and provides UI controls for sending new messages.
+
#### ConversationsHomeScreen
- Displays a list of all conversations with status indicators and message previews.
+
+Displays a list of all conversations with status indicators and message previews.
## Architecture and Data Flow
+
### Message Flow
#### 1. User Sends Message:
-* User enters text in ChatScreen and taps Send
-* ChatScreenViewModel processes the input
-* Message is first saved locally in the database
-* MessageNetworkHandler sends the message to recipient via HTTP
+
+- User enters text in ChatScreen and taps Send
+- ChatScreenViewModel processes the input
+- Message is first saved locally in the database
+- MessageNetworkHandler sends the message to recipient via HTTP
+
#### 2. Message Reception:
-* AppServer receives HTTP request on /chat endpoint
-* MessageNetworkHandler processes the incoming message
-* Message is stored in local database
-* ConversationRepository updates the conversation
-* UI is updated via StateFlow collection
+
+- AppServer receives HTTP request on /chat endpoint
+- MessageNetworkHandler processes the incoming message
+- Message is stored in local database
+- ConversationRepository updates the conversation
+- UI is updated via StateFlow collection
### Integration with Project Mesh Components
+
#### Network Integration
+
Messages are transmitted over the mesh network created by the Meshrabiya library. The system uses:
1. `AppServer`: Provides HTTP endpoints for receiving messages and handles file transfers.
2. `DeviceStatusManager`: Tracks online/offline status of devices to determine message deliverability.
3. `AndroidVirtualNode`: Manages the underlying mesh network connections.
-```kotlin
+```kotlin
// In AppServer.kt
// Handles incoming chat messages
else if(path.startsWith("/chat")) {
@@ -91,24 +117,26 @@ else if(path.startsWith("/chat")) {
val chatMessage = deserialzedJSON.content
val time = deserialzedJSON.dateReceived
val senderIp = deserialzedJSON.sender
-
+
// Handle message via MessageNetworkHandler
val message = MessageNetworkHandler.handleIncomingMessage(
chatMessage, time, senderIp, incomingfile
)
-
+
// Save to database
db.messageDao().addMessage(message)
}
```
-#### User System Integration
+#### User System Integration
+
Messages and conversations are linked to user profiles:
+
1. Each message contains a sender field with the username
2. Conversations use a composite ID created from the UUIDs of both participants
3. Online status is synchronized with the DeviceStatusManager
-```kotlin
+```kotlin
// In ConversationUtils.kt
fun createConversationId(uuid1: String, uuid2: String): String {
// Sort UUIDs to ensure consistent IDs regardless of sender/receiver
@@ -117,15 +145,21 @@ fun createConversationId(uuid1: String, uuid2: String): String {
```
#### Database Integration
+
The messaging module uses Room database for persistence:
+
1. **MeshDatabase**: Central database that contains tables for:
-* `messages`: Stores all message content
-* `conversations`: Stores conversation metadata
-* `users`: Stores user profile information
+
+- `messages`: Stores all message content
+- `conversations`: Stores conversation metadata
+- `users`: Stores user profile information
+
2. Relationship Flow:
-* Users have multiple Conversations
-* Conversations contain multiple Messages
-* Messages reference their Conversation via the chat field
+
+- Users have multiple Conversations
+- Conversations contain multiple Messages
+- Messages reference their Conversation via the chat field
+
```kotlin
// In MeshDatabase.kt
@Database(
@@ -143,35 +177,43 @@ abstract class MeshDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
}
```
+
## Special Features
+
### Offline Messaging
+
1. Messages are always stored locally first
2. If recipient is offline, message remains in local database
3. UI indicates delivery status based on device connectivity
4. Messages appear in conversation history regardless of delivery status
+
### Test Device Integration
+
Special handling for test devices that simulate real users:
-* Online test device automatically responds with echo messages
-* Offline test device stores messages locally but never receives them
+
+- Online test device automatically responds with echo messages
+- Offline test device stores messages locally but never receives them
### File Attachments
+
1. Messages can include file URI attachments
2. Files are transferred separately using the file transfer system
3. Messages with attachments display file indicators in the UI
-### Usage Example
-#### Conversations Screen:
+### Usage Example
+
+#### Conversations Screen:
-* Online and Offline Users Appear With Appropriate Read Receipts from Built-in Sample Messages
-* Connected device appears in the Conversation Screen
+- Online and Offline Users Appear With Appropriate Read Receipts from Built-in Sample Messages
+- Connected device appears in the Conversation Screen
-#### Chat Screen Initial Impressions
+#### Chat Screen Initial Impressions
-* When chatting for the first time a prompt appears to start a chat
+- When chatting for the first time a prompt appears to start a chat
#### Sending A Message
@@ -184,7 +226,7 @@ Special handling for test devices that simulate real users:
fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
val sendTime = System.currentTimeMillis()
val isOnline = DeviceStatusManager.isDeviceOnline(ipAddress)
-
+
// Create message entity
val messageEntity = Message(
id = 0,
@@ -194,17 +236,17 @@ fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
chat = chatName,
file = file
)
-
+
viewModelScope.launch {
// Save to local database
db.messageDao().addMessage(messageEntity)
-
+
// Update conversation
conversationRepository.updateWithMessage(
conversationId = conversation.id,
message = messageEntity
)
-
+
// Send message if recipient is online
if (isOnline) {
appServer.sendChatMessageWithStatus(
@@ -214,11 +256,12 @@ fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
}
}
```
-#### Receiving and Displaying Messages
+
+#### Receiving and Displaying Messages
**Example**: Bob Recieves Message From Alice
-1. Conversation is Updated and Read Receipt is shown:
+1. Conversation is Updated and Read Receipt is shown:
@@ -226,7 +269,7 @@ fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
-3. When Going back to the Chat Screen Read Status is Updated:
+3. When Going back to the Chat Screen Read Status is Updated:
@@ -243,45 +286,57 @@ init {
}
}
```
+
## Best Practices
+
### 1. Consider Network Conditions:
-* Always check device online status before sending
-* Provide clear UI feedback for undelivered messages
-* Handle intermittent connectivity gracefully
+
+- Always check device online status before sending
+- Provide clear UI feedback for undelivered messages
+- Handle intermittent connectivity gracefully
### 2. Database Operations:
-* Perform all database operations on IO dispatchers
-* Use Room's Flow API for reactive UI updates
-* Keep transactions atomic to prevent data corruption
+
+- Perform all database operations on IO dispatchers
+- Use Room's Flow API for reactive UI updates
+- Keep transactions atomic to prevent data corruption
### 3. User Experience:
-* Show clear online/offline indicators
-* Provide delivery status for messages
-* Update conversation timestamps and previews promptly
+
+- Show clear online/offline indicators
+- Provide delivery status for messages
+- Update conversation timestamps and previews promptly
### 4. Security Considerations:
-* Validate message content before processing
-* Use proper JSON schema validation for incoming messages
-* Sanitize user input to prevent injection attacks
+
+- Validate message content before processing
+- Use proper JSON schema validation for incoming messages
+- Sanitize user input to prevent injection attacks
## Troubleshooting
+
### Common Issues
+
#### 1. Messages Not Sending:
-* Check device status in DeviceStatusManager
-* Verify network connectivity between devices
-* Confirm AppServer is running on both devices
+
+- Check device status in DeviceStatusManager
+- Verify network connectivity between devices
+- Confirm AppServer is running on both devices
#### 2. Missing Conversations:
-* Ensure user profile exchange was successful
-* Check conversation ID generation is consistent
-* Verify database migrations have completed
+
+- Ensure user profile exchange was successful
+- Check conversation ID generation is consistent
+- Verify database migrations have completed
#### 3. UI Not Updating:
-* Confirm StateFlow collection is active
-* Check database queries are properly observed
-* Verify Composable recomposition triggers
+
+- Confirm StateFlow collection is active
+- Check database queries are properly observed
+- Verify Composable recomposition triggers
## Future Enhancements
+
1. **Message Encryption**: Add end-to-end encryption for message content
2. **Message Status**: Add read receipts and delivery confirmations
3. **Rich Media**: Enhance support for images, videos, and other media types
diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt
index 6fd45f979..632b84a84 100644
--- a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt
+++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt
@@ -12,68 +12,70 @@ import java.net.URLEncoder
import java.net.HttpURLConnection
import java.net.URL
-//Use this to encode files not just images
-//Needs to be tested sometime
-//Can I modify this so that Http transfer does the majority of the encoding?
-class FileEncoder {//Made by Craig. Encodes via base64.
+class FileEncoder {
@OptIn(ExperimentalEncodingApi::class)
- fun encodebase64(ctxt: Context, inputuri: Uri): String?{
- try {
+ fun encodebase64(ctxt: Context, inputuri: Uri): String? {
+ return try {
val encodedstrm: InputStream? = ctxt.contentResolver.openInputStream(inputuri)
val bytes = encodedstrm?.readBytes()
encodedstrm?.close()
- return if (bytes != null) {
- Base64.encode(bytes)
- } else {
- "Cannot encode file"
- }
- } catch(e: Exception){
+ encodeBytesBase64(bytes)
+ } catch (e: Exception) {
e.printStackTrace()
- return "Cannot encode file"}
+ "Cannot encode file"
+ }
+ }
+ @OptIn(ExperimentalEncodingApi::class)
+ internal fun encodeBytesBase64(bytes: ByteArray?): String? {
+ return if (bytes != null) {
+ Base64.encode(bytes)
+ } else {
+ "Cannot encode file"
+ }
}
- @OptIn(ExperimentalEncodingApi::class)//Made by Craig
- fun decodeBase64(inputbase64:String, output: File): File{//Decodes to a file. Uses base64
+ @OptIn(ExperimentalEncodingApi::class)
+ fun decodeBase64(inputbase64: String, output: File): File {
val decodedfilebytes = Base64.decode(inputbase64)
val decodedstrm = FileOutputStream(output)
decodedstrm.write(decodedfilebytes)
decodedstrm.close()
return output
}
- fun sendImage(imageURI: Uri?, tgtaddress: InetAddress, tgtport:Int, appctxt: Context): Boolean{//Testing sending images
- try{//we can utilize this if we opt not to use JSON
- if(imageURI != null){
- val fp = encodebase64(appctxt, imageURI)//encodes file to base64
- if(!fp.equals("Cannot encode file")) {
- val efp = URLEncoder.encode(fp, "UTF-8")//ensures that the file URI is utf-8 encoded
- val connection =
- URL("http://${tgtaddress.hostAddress}:${tgtport}/upload?file=$efp").openConnection() as HttpURLConnection
- val request = "POST"//Specifies the request as a POST
- connection.doOutput = true
- connection.requestMethod = request
- connection.setChunkedStreamingMode(0)
- val instream = appctxt.contentResolver.openInputStream(imageURI)
- val outstream = connection.outputStream
- val readingbuffer = ByteArray(1024)
- var finishedreading: Int
- while (instream?.read(readingbuffer).also { finishedreading = it!! } != -1) {
- outstream.write(readingbuffer, 0, finishedreading)
- }
- outstream.close()
- instream?.close()
+ fun sendImage(imageURI: Uri?, tgtaddress: InetAddress, tgtport: Int, appctxt: Context): Boolean {
+ try {
+ if (imageURI != null) {
+ val fp = encodebase64(appctxt, imageURI)
+ if (!fp.equals("Cannot encode file")) {
+ val efp = URLEncoder.encode(fp, "UTF-8")
+ val connection =
+ URL("http://${tgtaddress.hostAddress}:${tgtport}/upload?file=$efp").openConnection() as HttpURLConnection
+ val request = "POST"
+ connection.doOutput = true
+ connection.requestMethod = request
+ connection.setChunkedStreamingMode(0)
+ val instream = appctxt.contentResolver.openInputStream(imageURI)
+ val outstream = connection.outputStream
+ val readingbuffer = ByteArray(1024)
+ var finishedreading: Int
+ while (instream?.read(readingbuffer).also { finishedreading = it!! } != -1) {
+ outstream.write(readingbuffer, 0, finishedreading)
+ }
+ outstream.close()
+ instream?.close()
+ } else {
+ return false
+ }
} else {
return false
- }} else {
- return false
}
- }
- catch(e: Exception){
+ } catch (e: Exception) {
e.printStackTrace()
return false
}
return true
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt b/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt
index 804c51743..3335e86e3 100644
--- a/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt
+++ b/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt
@@ -8,43 +8,52 @@ import timber.log.Timber
*/
object Logger {
+ internal const val TAG_PREFIX = "MeshChat_"
private const val LOGGING_ENABLED = true
- private const val TAG_PREFIX = "MeshChat_"
+
+ internal fun buildTag(tag: String): String {
+ return "$TAG_PREFIX$tag"
+ }
+
+ internal fun buildCriticalTag(tag: String): String {
+ return "${TAG_PREFIX}${tag}_CRITICAL"
+ }
fun d(tag: String, message: String) {
if (LOGGING_ENABLED) {
- Timber.tag("$TAG_PREFIX$tag").d(message)
+ Timber.tag(buildTag(tag)).d(message)
}
}
fun i(tag: String, message: String) {
if (LOGGING_ENABLED) {
- Timber.tag("$TAG_PREFIX$tag").i(message)
+ Timber.tag(buildTag(tag)).i(message)
}
}
fun w(tag: String, message: String) {
if (LOGGING_ENABLED) {
- Timber.tag("$TAG_PREFIX$tag").w(message)
+ Timber.tag(buildTag(tag)).w(message)
}
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
if (LOGGING_ENABLED) {
if (throwable != null) {
- Timber.tag("$TAG_PREFIX$tag").e(throwable, message)
+ Timber.tag(buildTag(tag)).e(throwable, message)
} else {
- Timber.tag("$TAG_PREFIX$tag").e(message)
+ Timber.tag(buildTag(tag)).e(message)
}
}
}
// Log important events that should be visible even in production
fun critical(tag: String, message: String, throwable: Throwable? = null) {
+ val criticalTag = buildCriticalTag(tag)
if (throwable != null) {
- Timber.tag("$TAG_PREFIX${tag}_CRITICAL").e(throwable, message)
+ Timber.tag(criticalTag).e(throwable, message)
} else {
- Timber.tag("$TAG_PREFIX${tag}_CRITICAL").e(message)
+ Timber.tag(criticalTag).e(message)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt
index 708df0abe..68556aaed 100644
--- a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt
+++ b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt
@@ -93,7 +93,7 @@ class MessageMigrationUtils(
}
}
- private fun createConversationId(uuid1: String, uuid2: String): String {
+ internal fun createConversationId(uuid1: String, uuid2: String): String {
// Special cases for test devices
if (uuid2 == "test-device-uuid") {
return "local-user-test-device-uuid"
diff --git a/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt b/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt
index 256ba9422..df4821c7c 100644
--- a/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt
+++ b/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt
@@ -22,13 +22,6 @@ class InputStreamCounter(
}
}
- override fun read(b: ByteArray): Int {
- return super.read(b).also {
- if(it != -1)
- bytesRead += it
- }
- }
-
override fun read(b: ByteArray, off: Int, len: Int): Int {
return super.read(b, off, len).also {
if(it != -1)
diff --git a/app/src/main/java/com/greybox/projectmesh/testing/README.md b/app/src/main/java/com/greybox/projectmesh/testing/README.md
index d6a22a482..7b4f428cb 100644
--- a/app/src/main/java/com/greybox/projectmesh/testing/README.md
+++ b/app/src/main/java/com/greybox/projectmesh/testing/README.md
@@ -9,17 +9,17 @@ Project Mesh includes built-in test users to help with development and testing.
The application includes two test users:
1. **Online Test Device**
- - Name: "Test Echo Device (Online)"
- - IP Address: 192.168.0.99
- - Status: Always appears as online
- - Behavior: Automatically responds to messages with an echo reply
+ - Name: "Test Echo Device (Online)"
+ - IP Address: 192.168.0.99
+ - Status: Always appears as online
+ - Behavior: Automatically responds to messages with an echo reply
2. **Offline Test Device**
- - Name: "Test Echo Device (Offline)"
- - IP Address: 192.168.0.98
- - Status: Always appears as offline
- - Status: Always appears as offline
- - Behavior: Messages can be sent but will remain stored locally
+ - Name: "Test Echo Device (Offline)"
+ - IP Address: 192.168.0.98
+ - Status: Always appears as offline
+ - Status: Always appears as offline
+ - Behavior: Messages can be sent but will remain stored locally
**Only Online User Shows Up as Online**:
@@ -35,6 +35,7 @@ The application includes two test users:
## Usage Examples
### Testing Message Delivery
+
1. Navigate to the Chat screen
2. Select "Test Echo Device (Online)" from the conversation list
3. Send a message
@@ -43,6 +44,7 @@ The application includes two test users:
### Testing Offline Message Behavior
+
1. Navigate to the Chat screen
2. Select "Test Echo Device (Offline)" from the conversation list
3. Send a message
@@ -74,9 +76,12 @@ const val TEST_DEVICE_NAME_OFFLINE = "Test Echo Device (Offline)"
## Test User Integration
### How Test Users Connect with the Project Mesh Architecture
+
Test users are integrated into several key components of the Project Mesh architecture to simulate real devices without requiring actual physical connections. Here's how they interface with the core systems:
+
1. TestDeviceService Integration
-```kotlin
+
+```kotlin
// In TestDeviceService.kt
companion object {
const val TEST_DEVICE_IP = "192.168.0.99"
@@ -119,22 +124,24 @@ companion object {
Log.e("TestDeviceService", "Failed to initialize test device", e)
}
}
-
+
// Additional methods for test device functionality...
}
```
+
2. Global App Integration
-```kotlin
+
+```kotlin
// In GlobalApp.kt
override fun onCreate() {
super.onCreate()
-
+
// Other initialization code...
//Initialize test device:
TestDeviceService.initialize()
Log.d("MainActivity", "Test device initialized")
-
+
// Test conversation setup
insertTestConversations()
}
@@ -197,8 +204,10 @@ fun insertTestConversations() {
}
}
```
-3. AppServer Integration
-```kotlin
+
+3. AppServer Integration
+
+```kotlin
// In AppServer.kt
fun sendChatMessageWithStatus(address: InetAddress, time: Long, message: String, f: URI?): Boolean {
try {
@@ -218,7 +227,7 @@ fun sendChatMessageWithStatus(address: InetAddress, time: Long, message: String,
Log.d("AppServer", "Test device echoed message: $message")
return true
}
-
+
// Normal chat message handling for real devices...
}
catch (e: Exception) {
@@ -241,22 +250,24 @@ fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
DeviceStatusManager.updateDeviceStatus(ipAddress, false)
return
}
-
+
// Normal remote user info handling for real devices...
}
```
-4. DeviceStatusManager Integration
-```kotlin
+
+4. DeviceStatusManager Integration
+
+```kotlin
// In DeviceStatusManager.kt
object DeviceStatusManager {
// Other properties and methods...
-
+
//special test device addresses that should be handled differently
private val specialDevices = setOf(
"192.168.0.99", // Online test device
"192.168.0.98" // Offline test device
)
-
+
fun updateDeviceStatus(ipAddress: String, isOnline: Boolean, verified: Boolean = false) {
//if this is a special device, handle according to its predefined status
if (ipAddress == "192.168.0.99") { // Online test device
@@ -276,21 +287,23 @@ object DeviceStatusManager {
Log.d("DeviceStatusManager", "Updated test device status for $ipAddress: offline")
return
}
-
+
// Normal device status handling for real devices...
}
-
+
fun verifyDeviceStatus(ipAddress: String) {
// Skip verification for special test devices
if (ipAddress in specialDevices) {
return
}
-
+
// Normal device verification for real devices...
}
}
```
+
5. NetworkServiceViewModel Integration
+
```kotlin
// In NetworkScreenViewModel.kt
init {
@@ -313,21 +326,23 @@ init {
}
}
```
+
6. ConversationsHomeScreen Integration
Test devices appear in the conversations list as either online or offline contacts, with special handling to ensure they have the correct status regardless of actual network conditions.
7. ChatScreen Integration
-```kotlin
+
+```kotlin
// In ChatScreenViewModel.kt
fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
// Other processing...
-
+
viewModelScope.launch {
//save to local database
db.messageDao().addMessage(messageEntity)
//update convo with the new message
// ...
-
+
if (isOnline) {
try {
// Send message to real device
@@ -340,4 +355,4 @@ fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) {
}
```
-The test user system integrates smoothly with the existing architecture by implementing specialized handling at key decision points throughout the codebase. This allows the test devices to behave consistently and predictably while reusing much of the same code path as real devices.
\ No newline at end of file
+The test user system integrates smoothly with the existing architecture by implementing specialized handling at key decision points throughout the codebase. This allows the test devices to behave consistently and predictably while reusing much of the same code path as real devices.
diff --git a/app/src/main/java/com/greybox/projectmesh/user/README.md b/app/src/main/java/com/greybox/projectmesh/user/README.md
index 4b7a58b6d..b9ac34ebe 100644
--- a/app/src/main/java/com/greybox/projectmesh/user/README.md
+++ b/app/src/main/java/com/greybox/projectmesh/user/README.md
@@ -1,12 +1,16 @@
# User Profiles in Project Mesh
## Overview
+
Project Mesh implements a user profile system that allows devices to identify themselves on the mesh network. User profiles consist of a unique identifier (UUID), a display name, and network address information. This system enables personalized messaging and device identification across the mesh network.
+
## Key Components
+
### User Entity
+
The core of the user profile system is the UserEntity class, which stores all user data:
-```kotlin
+```kotlin
// In UserEntity.kt
@Serializable
@Entity(tableName = "users")
@@ -18,9 +22,11 @@ data class UserEntity(
)
```
-### User Repository
+### User Repository
+
The UserRepository manages all database operations related to user profiles:
-```kotlin
+
+```kotlin
// In UserRepository.kt
class UserRepository(private val userDao: UserDao) {
@@ -49,8 +55,11 @@ class UserRepository(private val userDao: UserDao) {
// Other repository methods...
}
```
+
### UserData Access Object (DAO)
+
The UserDao interface defines database operations:
+
```kotlin
// In UserDao.kt
@Dao
@@ -79,12 +88,14 @@ interface UserDao {
```
## User Profile Lifecycle
+
### First-time Setup
When a user first launches the app, they go through an onboarding process to set up their profile:
-```kotlin
+
+```kotlin
// In OnboardingViewModel.kt
fun handleFirstTimeSetup(onComplete: () -> Unit) {
viewModelScope.launch {
@@ -108,6 +119,7 @@ fun handleFirstTimeSetup(onComplete: () -> Unit) {
```
### User Information Exchange
+
When devices connect, they exchange user information:
**Before Name Exchange**:
@@ -117,11 +129,11 @@ When devices connect, they exchange user information:
-```kotlin
+```kotlin
// In AppServer.kt - requesting user info
fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
// Special handling for test devices...
-
+
scope.launch {
try {
val url = "http://${remoteAddr.hostAddress}:$port/myinfo"
@@ -130,7 +142,7 @@ fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
val response = httpClient.newCall(request).execute()
val userJson = response.body?.string()
-
+
if (!userJson.isNullOrEmpty()) {
// Decode JSON
val remoteUser = json.decodeFromString(UserEntity.serializer(), userJson)
@@ -141,7 +153,7 @@ fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
remoteUserWithIp.name,
remoteUserWithIp.address
)
-
+
// Update user status...
}
} catch (e: Exception) {
@@ -172,16 +184,17 @@ private fun handleMyInfoRequest(): Response {
```
### Profile Updates
+
Users can update their profile information in the Settings screen:
-*Found in Settings Under Network > Device Name*:
+_Found in Settings Under Network > Device Name_:
-*User name can be Updated* :
+_User name can be Updated_ :
-```kotlin
+```kotlin
// In SettingsScreen.kt
onDeviceNameChange = { newDeviceName ->
Log.d("BottomNavApp", "Device name changed to: $newDeviceName")
@@ -197,7 +210,7 @@ onDeviceNameChange = { newDeviceName ->
name = newDeviceName,
address = appServer.localVirtualAddr.hostAddress
)
-
+
// 2. Broadcast updated name to connected users
val connectedUsers = userRepository.getAllConnectedUsers()
connectedUsers.forEach { user ->
@@ -216,17 +229,19 @@ onDeviceNameChange = { newDeviceName ->
```
### Online Status Tracking
+
The application tracks which users are online using the DeviceStatusManager:
-```kotlin
+
+```kotlin
// In DeviceStatusManager.kt
object DeviceStatusManager {
private val _deviceStatusMap = MutableStateFlow>(emptyMap())
val deviceStatusMap: StateFlow> = _deviceStatusMap.asStateFlow()
-
+
// Updates a device's online status
fun updateDeviceStatus(ipAddress: String, isOnline: Boolean, verified: Boolean = false) {
// Special handling for test devices...
-
+
// For normal devices
if (verified) {
_deviceStatusMap.update { current ->
@@ -239,15 +254,18 @@ object DeviceStatusManager {
// Handle unverified status updates...
}
}
-
+
// Other status management methods...
}
```
+
## Integration with UI
+
User profiles are displayed in various parts of the UI:
-### Netowork List
-```kotlin
+### Netowork List
+
+```kotlin
// In WifiListItem.kt
@Composable
fun WifiListItem(
@@ -256,7 +274,7 @@ fun WifiListItem(
onClick: ((nodeAddress: String) -> Unit)? = null,
) {
// Other UI elements...
-
+
// obtain the device name according to the ip address
val user = runBlocking {
GlobalApp.GlobalUserRepo.userRepository.getUserByIp(wifiAddressDotNotation)
@@ -268,12 +286,12 @@ fun WifiListItem(
else {
Text(text = "Loading...", fontWeight = FontWeight.Bold)
}
-
+
// Other UI elements...
}
```
-### ChatScreen
+### ChatScreen
```kotlin
// In ChatScreen.kt
@@ -310,13 +328,13 @@ fun UserStatusBar(
// Row styling...
) {
// Status indicator dot...
-
+
// Status text
Text(
text = if (isOnline) "Online" else "Offline",
// Text styling...
)
-
+
// IP address
Text(
text = userAddress,
@@ -331,7 +349,7 @@ fun UserStatusBar(
### Conversations
-User profiles are linked to conversations for messaging:
+User profiles are linked to conversations for messaging:
```kotlin
// In ConversationRepository.kt
@@ -356,7 +374,7 @@ suspend fun getOrCreateConversation(localUuid: String, remoteUser: UserEntity):
)
conversationDao.insertConversation(conversation)
}
-
+
return conversation
}
```
@@ -370,6 +388,7 @@ suspend fun getOrCreateConversation(localUuid: String, remoteUser: UserEntity):
5. **Address Management**: IP addresses are managed dynamically based on network connectivity.
## Best Practices
+
When working with user profiles:
- **Always check for null**: IP addresses and user objects might be null, especially during initial setup.
@@ -390,4 +409,4 @@ When working with user profiles:
1. **Add profile pictures**: Consider adding the ability for users to set profile pictures.
2. **Enhance privacy options**: Allow users to control what information is shared.
-3. **Add user verification**: Implement a mechanism to verify user identities on the network.
\ No newline at end of file
+3. **Add user verification**: Implement a mechanism to verify user identities on the network.
diff --git a/app/src/main/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModel.kt b/app/src/main/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModel.kt
index b1b51708c..1a54e81fc 100644
--- a/app/src/main/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModel.kt
+++ b/app/src/main/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModel.kt
@@ -23,6 +23,7 @@ import org.kodein.di.instance
import timber.log.Timber
import java.net.InetAddress
+
data class SelectDestNodeScreenModel(
val allNodes: Map = emptyMap(),
val uris: List = emptyList(),
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 46d0ccf0c..2530ee71b 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,22 +1,23 @@
-
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index d1b5e673c..e2e8dfefb 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,81 +1,105 @@
-
-
-
-
+
+
+
+ android:fillColor="#FFFFFF"
+ android:pathData="M25,45 L55,30 L85,45 L55,60 Z"
+ />
+ android:fillColor="#404040"
+ android:pathData="M25,45 L55,60 L55,95 L25,80 Z"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="0.5"
+ android:pathData="M25,45 L55,60 L55,95 L25,80 Z"
+ />
+ android:fillColor="#404040"
+ android:pathData="M55,60 L85,45 L85,80 L55,95 Z"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="0.5"
+ android:pathData="M55,60 L85,45 L85,80 L55,95 Z"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M25,45 C30,40, 50,45, 55,60"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M31,42 C36,37, 56,42, 61,57"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M37,39 C42,34, 62,39, 67,54"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M43,36 C48,31, 68,36, 73,51"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M49,33 C54,28, 74,33, 79,48"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M31,48 C36,33, 56,28, 61,33"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M37,51 C42,36, 62,31, 67,36"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M43,54 C48,39, 68,34, 73,39"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M49,57 C54,42, 74,37, 79,42"
+ />
+ android:strokeColor="#404040"
+ android:strokeWidth="1"
+ android:pathData="M55,60 C60,45, 80,40, 85,45"
+ />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/drawable/splash_screen.xml b/app/src/main/res/drawable/splash_screen.xml
index e2151b40a..2a1e59208 100644
--- a/app/src/main/res/drawable/splash_screen.xml
+++ b/app/src/main/res/drawable/splash_screen.xml
@@ -1,7 +1,9 @@
-
+
-
+
diff --git a/app/src/main/res/layout/activity_crash_screen.xml b/app/src/main/res/layout/activity_crash_screen.xml
index 51904a590..c54696358 100644
--- a/app/src/main/res/layout/activity_crash_screen.xml
+++ b/app/src/main/res/layout/activity_crash_screen.xml
@@ -1,17 +1,23 @@
-
-
+
+
-
\ No newline at end of file
+ android:id="@+id/textView2"
+ android:layout_width="383dp"
+ android:layout_height="708dp"
+ android:text="TextView"
+ tools:layout_editor_absoluteX="13dp"
+ tools:layout_editor_absoluteY="14dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ />
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 339f413e4..6d6495a50 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,19 +1,21 @@
-
+
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+>
-
\ No newline at end of file
+ android:id="@+id/textView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="24dp"
+ android:layout_marginTop="24dp"
+ android:text="Project Mesh"
+ android:textSize="24sp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ />
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
index 6f3b755bf..e0a723c9e 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -1,6 +1,6 @@
-
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
index 6f3b755bf..e0a723c9e 100644
--- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -1,6 +1,6 @@
-
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-cn/strings.xml b/app/src/main/res/values-cn/strings.xml
index fe707285c..74cd35de2 100644
--- a/app/src/main/res/values-cn/strings.xml
+++ b/app/src/main/res/values-cn/strings.xml
@@ -4,15 +4,21 @@
启动热点
停止热点
通过二维码扫描连接
- 通过输入链接地址连接
+ 通过输入链接地址连接
分享链接地址
WiFi站(客户端)连接
扫描另一台设备加入网格
热点状态
在线
离线
- 或者,在下方输入链接地址:\n1. 确保蓝牙已开启\n2. 允许所有人通过快速分享共享数据\n3. 获得链接地址之后,点击复制\n4. 将链接粘贴到下方文本框\n5. 点击“通过输入链接地址连接”
- 要分享连接URI:\n1. 确保此设备上的蓝牙已启用\n2. 点击“分享连接URI”\n3. 选择快速分享\n4. 选择您想共享URI的附近设备
+ 或者,在下方输入链接地址:\n1. 确保蓝牙已开启\n2. 允许所有人通过快速分享共享数据\n3. 获得链接地址之后,点击复制\n4. 将链接粘贴到下方文本框\n5. 点击“通过输入链接地址连接”
+ 要分享连接URI:\n1. 确保此设备上的蓝牙已启用\n2. 点击“分享连接URI”\n3. 选择快速分享\n4. 选择您想共享URI的附近设备
输入链接地址(以"meshrabiya://"开头)
发送文件
来自
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index ec9bec573..c5b985a73 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -3,17 +3,31 @@
Dirección IP
Iniciar Punto de Acceso
Detener Punto de Acceso
- Conectar mediante Escaneo de Código QR
- Conectar ingresando URI de Conexión
+ Conectar mediante Escaneo de Código QR
+ Conectar ingresando URI de Conexión
Compartir URI de Conexión
- Conexión de Estación Wifi (Cliente)
- Escanea otro dispositivo para unirse a la red Mesh
+ Conexión de Estación Wifi (Cliente)
+ Escanea otro dispositivo para unirse a la red Mesh
Estado del Punto de Acceso
En línea
Desconectado
- O, ingrese el URI de conexión a continuación:\n1. Asegúrese de que el Bluetooth esté activado\n2. Permita que todos compartan datos mediante Quick Share\n3. Después de obtener el URI, haga clic en copiar\n4. Pegue el URI en el campo de texto a continuación\n5. Haga clic en "Conectar ingresando URI de Conexión"
- Para compartir el URI de conexión:\n1. Asegúrese de que Bluetooth esté habilitado en este dispositivo\n2. Haga clic en "Compartir URI de Conexión"\n3. Seleccione Quick Share\n4. Elija un dispositivo cercano con el que desea compartir el URI
- Ingrese URI de Conexión (Empieza con "meshrabiya://")
+ O, ingrese el URI de conexión a continuación:\n1. Asegúrese de que el Bluetooth esté activado\n2. Permita que todos compartan datos mediante Quick Share\n3. Después de obtener el URI, haga clic en copiar\n4. Pegue el URI en el campo de texto a continuación\n5. Haga clic en "Conectar ingresando URI de Conexión"
+ Para compartir el URI de conexión:\n1. Asegúrese de que Bluetooth esté habilitado en este dispositivo\n2. Haga clic en "Compartir URI de Conexión"\n3. Seleccione Quick Share\n4. Elija un dispositivo cercano con el que desea compartir el URI
+ Ingrese URI de Conexión (Empieza con "meshrabiya://")
Enviar Archivo
De
Estado
diff --git a/app/src/main/res/values-fr-rCA/strings.xml b/app/src/main/res/values-fr-rCA/strings.xml
index 1270e1527..e7c73876d 100644
--- a/app/src/main/res/values-fr-rCA/strings.xml
+++ b/app/src/main/res/values-fr-rCA/strings.xml
@@ -1,15 +1,23 @@
-
+
Project Mesh
Addresse IP
Démarrer le point d’accès
Arrêter le point d’accès
- Connectez-vous via le scan du Code QR
- Se connecter en saisissant l’URI de
+ Connectez-vous via le scan du Code QR
+ Se connecter en saisissant l’URI de
Connexion
Partager l’URI de Connexion
- Connexion à la station WIFI (client)
- Scannez un autre appareil pour rejoindre le Mesh
+ Connexion à la station WIFI (client)
+ Scannez un autre appareil pour rejoindre le Mesh
Statut du point d’accès
En ligne
Hors ligne
@@ -20,13 +28,17 @@
"4. Collez l’URI dans le champ de texte ci-dessous\n"
"5. Cliquez sur « Se connecter via la saisie de l'URI de connexion »""
- "Poru partager l’URI de connexion:\n"
+ "Poru partager l’URI de connexion:\n"
"1. Assurez-vous que le Bluetooth est activé sur cet appareil\n"
"2. Cliquez sur « Partager l’URI de connexion »"\n"
"3. Select Quick Share\n"
"4. Choisissez un appareil à proximité avec lequel vous souhaitez partager l’URI."
- Entrez l’URI de connexion (Commence par "meshrabiya://")
+ Entrez l’URI de connexion (Commence par "meshrabiya://")
Envoyer un fichier
De
@@ -51,7 +63,9 @@
Bande
Type de point d’accès
- Rechercher des appareils à proximité
+ Rechercher des appareils à proximité
Recherche en cours
Annuler
@@ -60,4 +74,5 @@
Concurrence STA/AP
Test manuel
Réinitialiser
-
\ No newline at end of file
+ Registre
+
diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml
index d53ca56bf..11d77d194 100644
--- a/app/src/main/res/values-night/styles.xml
+++ b/app/src/main/res/values-night/styles.xml
@@ -3,4 +3,4 @@
- @color/gray_600
- @color/light_blue_600
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/attrs_main_view.xml b/app/src/main/res/values/attrs_main_view.xml
index 3a237aa94..a09f505d0 100644
--- a/app/src/main/res/values/attrs_main_view.xml
+++ b/app/src/main/res/values/attrs_main_view.xml
@@ -5,4 +5,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 66e3f6cfb..dc51e0a56 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,4 +1,4 @@
-
+
#FFBB86FC
#FF6200EE
@@ -11,4 +11,4 @@
#FF039BE5
#FFBDBDBD
#FF757575
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d5c5fdc0e..2a11ce418 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,10 +4,16 @@
Start Hotspot
Stop Hotspot
Connect via QR Code Scan
- Connect via Entering Connect URI
+ Connect via Entering Connect URI
Share connect URI
- Wifi Station (Client) Connection
- Scan another device to join the Mesh
+ Wifi Station (Client) Connection
+ Scan another device to join the Mesh
Hotspot Status
Online
Offline
@@ -24,7 +30,9 @@
"3. Select Quick Share\n"
"4. Choose a nearby device you want to share the URI with"
- Enter Connect URI (Starts with "meshrabiya://")
+ Enter Connect URI (Starts with "meshrabiya://")
Send File
From
@@ -60,4 +68,4 @@
Chat
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index e3ef8e827..5914c4940 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -3,4 +3,4 @@
- @color/gray_400
- @color/light_blue_400
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 8f7e83969..47e238e73 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,7 +1,9 @@
-
-
+
-
@@ -10,4 +12,4 @@
- @drawable/splash_screen
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index fa0f996d2..8f6a5d2d7 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -1,4 +1,5 @@
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 9ee9997b0..57d37d48b 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -1,4 +1,5 @@
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml
index 4dec07c04..eaba7f643 100644
--- a/app/src/main/res/xml/filepaths.xml
+++ b/app/src/main/res/xml/filepaths.xml
@@ -1,4 +1,4 @@
-
+
-
-
\ No newline at end of file
+
+
diff --git a/app/src/test/java/com/greybox/projectmesh/DeviceStatusManagerTest.kt b/app/src/test/java/com/greybox/projectmesh/DeviceStatusManagerTest.kt
new file mode 100644
index 000000000..1a406b077
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/DeviceStatusManagerTest.kt
@@ -0,0 +1,151 @@
+package com.greybox.projectmesh
+
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class DeviceStatusManagerTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
+
+ @Before
+ fun setUp() {
+ DeviceStatusManager.clearAllStatuses()
+ clearPrivateMutableMap("failureCountMap")
+
+ mockkObject(DeviceStatusManager)
+ every { DeviceStatusManager.updateDeviceStatus(any(), any(), any()) } answers { callOriginal() }
+ every { DeviceStatusManager.isDeviceOnline(any()) } answers { callOriginal() }
+ every { DeviceStatusManager.getOnlineDevices() } answers { callOriginal() }
+ every { DeviceStatusManager.clearAllStatuses() } answers { callOriginal() }
+ every { DeviceStatusManager.handleNetworkDisconnect(any()) } answers { callOriginal() }
+
+ every { DeviceStatusManager.verifyDeviceStatus(any()) } just Runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(DeviceStatusManager)
+ clearAllMocks()
+
+ DeviceStatusManager.clearAllStatuses()
+ clearPrivateMutableMap("failureCountMap")
+ clearPrivateMutableMap("lastCheckedTimes")
+ }
+
+ @Test
+ fun updateDeviceStatus_specialOnlineDevice_alwaysForcesOnlineAndSkipsVerify() = runTest {
+ val ip = "192.168.0.99"
+
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = false, verified = false)
+
+ assertTrue(DeviceStatusManager.deviceStatusMap.value[ip] == true)
+ verify(exactly = 0) { DeviceStatusManager.verifyDeviceStatus(any()) }
+ }
+
+ @Test
+ fun updateDeviceStatus_specialOfflineDevice_alwaysForcesOfflineAndSkipsVerify() = runTest {
+ val ip = "192.168.0.98"
+
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = true, verified = true)
+
+ assertTrue(DeviceStatusManager.deviceStatusMap.value[ip] == false)
+ verify(exactly = 0) { DeviceStatusManager.verifyDeviceStatus(any()) }
+ }
+
+ @Test
+ fun updateDeviceStatus_verifiedUpdate_setsStatusAndUpdatesLastCheckedTimes() = runTest {
+ val ip = "10.0.0.55"
+
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = true, verified = true)
+
+ assertTrue(DeviceStatusManager.deviceStatusMap.value[ip] == true)
+
+ val lastChecked = getPrivateMutableMap("lastCheckedTimes")[ip]
+ assertNotNull(lastChecked)
+ assertTrue(lastChecked!! > 0L)
+ }
+
+ @Test
+ fun updateDeviceStatus_unverifiedOnlineFromUnknown_setsOnlineAndTriggersVerify() = runTest {
+ val ip = "10.0.0.77"
+
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = true, verified = false)
+
+ assertTrue(DeviceStatusManager.deviceStatusMap.value[ip] == true)
+ verify(exactly = 1) { DeviceStatusManager.verifyDeviceStatus(ip) }
+ }
+
+ @Test
+ fun updateDeviceStatus_unverifiedOffline_triggersVerify() = runTest {
+ val ip = "10.0.0.88"
+
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = false, verified = false)
+
+ verify(exactly = 1) { DeviceStatusManager.verifyDeviceStatus(ip) }
+ }
+
+ @Test
+ fun isDeviceOnline_whenOnlineAndStale_triggersVerifyAndReturnsTrue() = runTest {
+ val ip = "10.0.0.99"
+
+ // mark online
+ DeviceStatusManager.updateDeviceStatus(ipAddress = ip, isOnline = true, verified = true)
+
+ // force "stale" last-checked time so isDeviceOnline will verify again
+ val lastCheckedTimes = getPrivateMutableMap("lastCheckedTimes")
+ lastCheckedTimes[ip] = 0L
+
+ val online = DeviceStatusManager.isDeviceOnline(ip)
+
+ assertTrue(online)
+ verify(exactly = 1) { DeviceStatusManager.verifyDeviceStatus(ip) }
+ }
+
+ @Test
+ fun getOnlineDevices_returnsOnlyIpsMarkedTrue() = runTest {
+ DeviceStatusManager.updateDeviceStatus("10.0.0.1", true, verified = true)
+ DeviceStatusManager.updateDeviceStatus("10.0.0.2", false, verified = true)
+ DeviceStatusManager.updateDeviceStatus("10.0.0.3", true, verified = true)
+
+ val online = DeviceStatusManager.getOnlineDevices()
+
+ assertEquals(setOf("10.0.0.1", "10.0.0.3"), online.toSet())
+ assertFalse(online.contains("10.0.0.2"))
+ }
+
+ private fun clearPrivateMutableMap(fieldName: String) {
+ val map = getPrivateMutableMap(fieldName)
+ map.clear()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun getPrivateMutableMap(fieldName: String): MutableMap {
+ val clazz = DeviceStatusManager::class.java
+ val field = clazz.getDeclaredField(fieldName).apply { isAccessible = true }
+ return field.get(null) as MutableMap
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/MNetLoggerAndroidTest.kt b/app/src/test/java/com/greybox/projectmesh/MNetLoggerAndroidTest.kt
new file mode 100644
index 000000000..8e6978118
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/MNetLoggerAndroidTest.kt
@@ -0,0 +1,123 @@
+package com.greybox.projectmesh
+
+import android.content.Context
+import android.util.Log
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.extension.deviceInfo
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.flow.first
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], manifest = Config.NONE)
+class MNetLoggerAndroidTest {
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ mockkStatic("com.greybox.projectmesh.extension.ContextExtKt")
+ every { Log.v(any(), any(), any()) } returns 0
+ every { Log.d(any(), any(), any()) } returns 0
+ every { Log.i(any(), any(), any()) } returns 0
+ every { Log.w(any(), any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+ every { Log.wtf(any(), any(), any()) } returns 0
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Log::class)
+ unmockkStatic("com.greybox.projectmesh.extension.ContextExtKt")
+ clearAllMocks()
+ }
+
+ @Test
+ fun invoke_belowMinLogLevel_isIgnored() {
+ val logger = MNetLoggerAndroid(deviceInfo = "device", minLogLevel = Log.INFO)
+
+ logger.invoke(Log.DEBUG, "debug", null)
+
+ assertEquals(emptyList(), latestLogs(logger))
+ }
+
+ @Test
+ fun invoke_prependsNewestLogsAndTrimsHistory() {
+ val logger = MNetLoggerAndroid(
+ deviceInfo = "device",
+ minLogLevel = Log.VERBOSE,
+ logHistoryLines = 2
+ )
+
+ logger.invoke(Log.INFO, "first", null)
+ logger.invoke(Log.ERROR, "second", IllegalStateException("boom"))
+ logger.invoke(Log.WARN, "third", null)
+
+ val logs = latestLogs(logger)
+ assertEquals(2, logs.size)
+ assertEquals("third", logs[0].line)
+ assertTrue(logs[1].line.contains("second"))
+ assertTrue(logs[1].line.contains("boom"))
+ }
+
+ @Test
+ fun invoke_whenLogFileConfigured_writesSessionHeaderAndMessages() {
+ val logFile = File.createTempFile("mesh-logger", ".log").apply {
+ delete()
+ deleteOnExit()
+ }
+ val logger = MNetLoggerAndroid(
+ deviceInfo = "Device Info",
+ minLogLevel = Log.VERBOSE,
+ logFile = logFile
+ )
+
+ logger.invoke(Log.INFO, "persisted", null)
+
+ val text = eventuallyRead(logFile)
+ assertTrue(text.contains("Meshrabiya Session start"))
+ assertTrue(text.contains("Device Info"))
+ assertTrue(text.contains("persisted"))
+ }
+
+ @Test
+ fun exportAsString_includesContextDeviceInfoAndLogs() {
+ val context = ApplicationProvider.getApplicationContext()
+ every { context.deviceInfo() } returns "CTX INFO\n"
+ val logger = MNetLoggerAndroid(deviceInfo = "ctor info")
+ logger.invoke(Log.INFO, "export me", null)
+
+ val export = logger.exportAsString(context)
+
+ assertTrue(export.contains("CTX INFO"))
+ assertTrue(export.contains("==Logs=="))
+ assertTrue(export.contains("export me"))
+ }
+
+ private fun latestLogs(logger: MNetLoggerAndroid) = kotlinx.coroutines.runBlocking {
+ logger.recentLogs.first()
+ }
+
+ private fun eventuallyRead(file: File): String {
+ repeat(50) {
+ if (file.exists()) {
+ val text = file.readText()
+ if (text.contains("persisted")) {
+ return text
+ }
+ }
+ Thread.sleep(50)
+ }
+ return if (file.exists()) file.readText() else ""
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt b/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt
new file mode 100644
index 000000000..12687ec29
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt
@@ -0,0 +1,127 @@
+package com.greybox.projectmesh.components
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.lang.reflect.Field
+import com.ustadmobile.meshrabiya.vnet.wifi.WifiConnectConfig
+
+/**
+ * JVM-only tests for simple data + status in WifiConnection.kt.
+ * No Android/Compose/Mockito required.
+ *
+ * NOTE: We allocate a dummy WifiConnectConfig via Unsafe and NEVER call its methods.
+ * We also avoid ConnectRequest.equals()/hashCode() because that would call
+ * WifiConnectConfig.hashCode() internally (which can NPE if fields are null).
+ */
+class WifiConnectionTest {
+
+ // -----------------------------
+ // Enum: stages must exist
+ // -----------------------------
+ @Test
+ fun checkAllStatusesExist() {
+ val all = ConnectWifiLauncherStatus.values().toSet()
+ assertTrue(ConnectWifiLauncherStatus.INACTIVE in all)
+ assertTrue(ConnectWifiLauncherStatus.REQUESTING_PERMISSION in all)
+ assertTrue(ConnectWifiLauncherStatus.LOOKING_FOR_NETWORK in all)
+ assertTrue(ConnectWifiLauncherStatus.REQUESTING_LINK in all)
+ assertEquals(4, all.size)
+ }
+
+ // -----------------------------------------------
+ // Result model: failure shape must look correct
+ // -----------------------------------------------
+ @Test
+ fun checkFailureResultLooksRight() {
+ val error = Exception("expected failure")
+ val result = ConnectWifiLauncherResult(
+ hotspotConfig = null,
+ exception = error,
+ isWifiConnected = false
+ )
+ assertFalse(result.isWifiConnected)
+ assertNull(result.hotspotConfig)
+ assertNotNull(result.exception)
+ assertEquals("expected failure", result.exception?.message)
+ }
+
+ // -------------------------------------------------------
+ // Result model: data-class copy/equals/hashCode sanity
+ // (safe because hotspotConfig = null)
+ // -------------------------------------------------------
+ @Test
+ fun checkResultCopiesAndComparesCorrectly() {
+ val first = ConnectWifiLauncherResult(
+ hotspotConfig = null,
+ exception = Exception("boom"),
+ isWifiConnected = false
+ )
+ val same = first.copy()
+ val different = first.copy(exception = Exception("other"))
+
+ assertEquals(first, same)
+ assertEquals(first.hashCode(), same.hashCode())
+ assertNotEquals(first, different)
+ }
+
+ // =========================================
+ // ConnectRequest: JVM tests (no Mockito)
+ // Avoid equals()/hashCode() on the whole object.
+ // =========================================
+
+ @Test
+ fun connectRequest_defaultTimeIsZero() {
+ val cfg = unsafeInstance()
+ val req = ConnectRequest(connectConfig = cfg)
+ assertEquals(0L, req.receivedTime)
+ assertSame(cfg, req.connectConfig) // same reference
+ }
+
+ @Test
+ fun connectRequest_customTimePreserved() {
+ val cfg = unsafeInstance()
+ val req = ConnectRequest(receivedTime = 123456789L, connectConfig = cfg)
+ assertEquals(123456789L, req.receivedTime)
+ assertSame(cfg, req.connectConfig)
+ }
+
+ @Test
+ fun connectRequest_copyPreservesConfigAndChangesTime() {
+ val cfg = unsafeInstance()
+ val original = ConnectRequest(receivedTime = 100L, connectConfig = cfg)
+ val copiedSame = original.copy()
+ val copiedChanged = original.copy(receivedTime = 200L)
+
+ // field-wise checks (no equals()/hashCode())
+ assertEquals(100L, copiedSame.receivedTime)
+ assertSame(cfg, copiedSame.connectConfig)
+
+ assertEquals(200L, copiedChanged.receivedTime)
+ assertSame(cfg, copiedChanged.connectConfig)
+
+ // sanity: copies are distinct instances
+ assertNotSame(original, copiedSame)
+ assertNotSame(original, copiedChanged)
+ }
+
+ // -------------------------------------------------------
+ // Tiny Unsafe helper for constructor-less allocation
+ // -------------------------------------------------------
+ @Suppress("UNCHECKED_CAST")
+ private inline fun unsafeInstance(): T {
+ val unsafe = getUnsafe()
+ val allocate = unsafe.javaClass.getMethod("allocateInstance", Class::class.java)
+ return allocate.invoke(unsafe, T::class.java) as T
+ }
+
+ private fun getUnsafe(): Any {
+ val clazz = try {
+ Class.forName("sun.misc.Unsafe")
+ } catch (_: ClassNotFoundException) {
+ Class.forName("jdk.internal.misc.Unsafe")
+ }
+ val f: Field = clazz.getDeclaredField("theUnsafe")
+ f.isAccessible = true
+ return f.get(null)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt b/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt
new file mode 100644
index 000000000..e8d812006
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt
@@ -0,0 +1,54 @@
+package com.greybox.projectmesh.db
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.lang.reflect.Modifier
+import com.greybox.projectmesh.messaging.data.dao.MessageDao
+import com.greybox.projectmesh.messaging.data.dao.ConversationDao
+import com.greybox.projectmesh.user.UserDao
+import androidx.room.RoomDatabase
+
+/**
+ * JVM-only tests for MeshDatabase class shape.
+ * These do NOT spin up Room or touch Android runtime.
+ *
+ * We verify:
+ * - MeshDatabase is abstract
+ * - MeshDatabase extends RoomDatabase
+ * - Required DAO methods exist and have the correct return types
+ *
+ * Anything involving entities, queries, schema, and migrations belongs
+ * in src/androidTest with an in-memory RoomDatabase.
+ */
+class MeshDatabaseTest {
+
+ @Test
+ fun meshDatabase_isAbstract_and_extendsRoomDatabase() {
+ val cls = MeshDatabase::class.java
+
+ // Must be abstract
+ assertTrue("MeshDatabase must be abstract",
+ Modifier.isAbstract(cls.modifiers))
+
+ // Must extend androidx.room.RoomDatabase
+ assertTrue("MeshDatabase must extend RoomDatabase",
+ RoomDatabase::class.java.isAssignableFrom(cls))
+ }
+
+ @Test
+ fun meshDatabase_has_requiredDaoMethods_with_correctReturnTypes() {
+ val cls = MeshDatabase::class.java
+
+ // messageDao(): MessageDao
+ val messageDaoMethod = cls.getMethod("messageDao")
+ assertEquals(MessageDao::class.java, messageDaoMethod.returnType)
+
+ // userDao(): UserDao
+ val userDaoMethod = cls.getMethod("userDao")
+ assertEquals(UserDao::class.java, userDaoMethod.returnType)
+
+ // conversationDao(): ConversationDao
+ val conversationDaoMethod = cls.getMethod("conversationDao")
+ assertEquals(ConversationDao::class.java, conversationDaoMethod.returnType)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt b/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt
new file mode 100644
index 000000000..f9a18bf26
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt
@@ -0,0 +1,88 @@
+package com.greybox.projectmesh.debug
+
+import com.google.gson.Gson
+import org.junit.Assert.*
+import org.junit.Test
+import java.lang.Thread.UncaughtExceptionHandler
+
+/**
+ * JVM-only tests for CrashHandler.
+ * We DO NOT invoke Android runtime (no Context/Intent usage at runtime).
+ * All checks are reflection-based and safe for plain JVM.
+ */
+class CrashHandlerTest {
+
+ /**
+ * CrashHandler must implement Thread.UncaughtExceptionHandler.
+ * (If someone removes/changes this, we catch it early.)
+ */
+ @Test
+ fun crashHandler_implements_UncaughtExceptionHandler() {
+ assertTrue(
+ UncaughtExceptionHandler::class.java.isAssignableFrom(CrashHandler::class.java)
+ )
+ }
+
+ /**
+ * Verify the primary constructor shape:
+ * (Context, UncaughtExceptionHandler, Class<*>)
+ * We don't instantiate anything; we only look up the signature.
+ */
+ @Test
+ fun crashHandler_has_expected_constructor_signature() {
+ val ctx = android.content.Context::class.java
+ val ueh = UncaughtExceptionHandler::class.java
+ val klass = Class::class.java
+
+ // If the constructor is missing or signature changes, this throws NoSuchMethodException
+ val ctor = CrashHandler::class.java.getDeclaredConstructor(ctx, ueh, klass)
+ assertNotNull(ctor)
+ }
+
+ /**
+ * Companion must expose:
+ * - init(Context, Class<*>)
+ * - getThrowableFromIntent(Intent): Throwable?
+ * We assert presence and parameter/return types by reflection.
+ */
+ @Test
+ fun companion_has_init_and_getThrowableFromIntent_signatures() {
+ // Access the Kotlin "Companion" object using plain Java reflection.
+ val companionField = CrashHandler::class.java.getDeclaredField("Companion")
+ companionField.isAccessible = true
+ val companion = companionField.get(null)
+ ?: throw AssertionError("CrashHandler must have a companion object")
+
+ val companionClass = companion::class.java
+
+ // init(Context, Class<*>)
+ val ctx = android.content.Context::class.java
+ val klass = Class::class.java
+ val initMethod = companionClass.getMethod("init", ctx, klass)
+ assertEquals(Void.TYPE, initMethod.returnType)
+
+ // getThrowableFromIntent(Intent): Throwable?
+ val intent = android.content.Intent::class.java
+ val getMethod = companionClass.getMethod("getThrowableFromIntent", intent)
+ assertTrue(Throwable::class.java.isAssignableFrom(getMethod.returnType))
+ // Nullable return can't be asserted at runtime; we only check the declared type.
+ }
+
+ /**
+ * Sanity: the Gson strategy (JSON round-trip) works in general.
+ * We DON'T use Throwable here because JDK 17+ blocks reflective access
+ * to its internal fields, which causes JsonIOException.
+ * Real Throwable JSON handling will be validated in instrumented tests.
+ */
+ @Test
+ fun gson_can_roundtrip_simple_crash_payload() {
+ data class CrashPayload(val message: String)
+
+ val gson = Gson()
+ val original = CrashPayload("crash-demo")
+ val json = gson.toJson(original)
+ val parsed = gson.fromJson(json, CrashPayload::class.java)
+
+ assertEquals("crash-demo", parsed.message)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt b/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt
new file mode 100644
index 000000000..a83851cd6
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt
@@ -0,0 +1,48 @@
+package com.greybox.projectmesh.debug
+
+import android.content.Intent
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class CrashScreenActivityTest {
+
+ @Test
+ fun getThrowableFromIntent_returnsThrowable_whenValidJsonProvided() {
+ // SAFE: minimal JSON, avoids Gson reflection into private fields.
+ val json = """{"detailMessage":"boom-crash"}"""
+
+ val intent = Intent().apply {
+ putExtra("CrashData", json)
+ }
+
+ val parsed = CrashHandler.getThrowableFromIntent(intent)
+
+ assertNotNull(parsed)
+ assertEquals("boom-crash", parsed?.message)
+ }
+
+ @Test
+ fun getThrowableFromIntent_returnsNull_whenInvalidJsonProvided() {
+ val intent = Intent().apply {
+ putExtra("CrashData", "{invalid-json}")
+ }
+
+ val parsed = CrashHandler.getThrowableFromIntent(intent)
+
+ assertNull(parsed)
+ }
+
+ @Test
+ fun getThrowableFromIntent_returnsNull_whenNoCrashDataProvided() {
+ val intent = Intent()
+
+ val parsed = CrashHandler.getThrowableFromIntent(intent)
+
+ assertNull(parsed)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/extension/ContentResolverExtensionTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/ContentResolverExtensionTest.kt
new file mode 100644
index 000000000..bab333857
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/extension/ContentResolverExtensionTest.kt
@@ -0,0 +1,119 @@
+package com.greybox.projectmesh.extension
+
+import android.content.ContentResolver
+import android.database.Cursor
+import android.net.Uri
+import android.provider.OpenableColumns
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+
+class ContentResolverExtensionTest {
+
+ @Test
+ fun getUriNameAndSize_whenFileScheme_returnsFileNameAndLength() {
+ val tmp = File.createTempFile("pm_test_", ".bin")
+ tmp.writeBytes(ByteArray(5) { 1 })
+ tmp.deleteOnExit()
+
+ val uri = Uri.fromFile(tmp)
+ val resolver = mockk(relaxed = true)
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(tmp.name, result.name)
+ assertEquals(tmp.length(), result.size)
+ }
+
+ @Test
+ fun getUriNameAndSize_whenQueryReturnsNull_returnsNullAndMinusOne() {
+ val uri = Uri.parse("content://test/nope")
+ val resolver = mockk()
+ every { resolver.query(uri, null, null, null, null) } returns null
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(UriNameAndSize(null, -1L), result)
+ verify(exactly = 1) { resolver.query(uri, null, null, null, null) }
+ }
+
+ @Test
+ fun getUriNameAndSize_whenCursorMoveToFirstFalse_returnsNullAndMinusOne() {
+ val uri = Uri.parse("content://test/empty")
+ val resolver = mockk()
+ val cursor = mockk(relaxed = true)
+
+ every { resolver.query(uri, null, null, null, null) } returns cursor
+ every { cursor.moveToFirst() } returns false
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(UriNameAndSize(null, -1L), result)
+ verify(exactly = 1) { cursor.close() }
+ }
+
+ @Test
+ fun getUriNameAndSize_whenColumnIndicesAreZero_returnsNullAndMinusOne_dueToIndexCheck() {
+ val uri = Uri.parse("content://test/cols0")
+ val resolver = mockk()
+ val cursor = mockk(relaxed = true)
+
+ every { resolver.query(uri, null, null, null, null) } returns cursor
+ every { cursor.moveToFirst() } returns true
+ every { cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) } returns 0
+ every { cursor.getColumnIndex(OpenableColumns.SIZE) } returns 0
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(UriNameAndSize(null, -1L), result)
+ verify(exactly = 1) { cursor.close() }
+ }
+
+ @Test
+ fun getUriNameAndSize_whenSizeIsNull_returnsNameAndMinusOne() {
+ val uri = Uri.parse("content://test/sizeNull")
+ val resolver = mockk()
+ val cursor = mockk(relaxed = true)
+
+ every { resolver.query(uri, null, null, null, null) } returns cursor
+ every { cursor.moveToFirst() } returns true
+ every { cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) } returns 1
+ every { cursor.getColumnIndex(OpenableColumns.SIZE) } returns 2
+ every { cursor.isNull(2) } returns true
+ every { cursor.getString(1) } returns "hello.txt"
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(UriNameAndSize("hello.txt", -1L), result)
+ verify(exactly = 1) { cursor.close() }
+ }
+
+ @Test
+ fun getUriNameAndSize_whenSizeIsPresent_returnsNameAndSize() {
+ val uri = Uri.parse("content://test/sizeOk")
+ val resolver = mockk()
+ val cursor = mockk(relaxed = true)
+
+ every { resolver.query(uri, null, null, null, null) } returns cursor
+ every { cursor.moveToFirst() } returns true
+ every { cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) } returns 1
+ every { cursor.getColumnIndex(OpenableColumns.SIZE) } returns 2
+ every { cursor.isNull(2) } returns false
+ every { cursor.getString(1) } returns "world.bin"
+ every { cursor.getString(2) } returns "12345"
+
+ val result = resolver.getUriNameAndSize(uri)
+
+ assertEquals(UriNameAndSize("world.bin", 12345L), result)
+ verify(exactly = 1) { cursor.close() }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/extension/ContextExtTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/ContextExtTest.kt
new file mode 100644
index 000000000..892ecfb24
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/extension/ContextExtTest.kt
@@ -0,0 +1,130 @@
+package com.greybox.projectmesh.extension
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.wifi.WifiManager
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], manifest = Config.NONE)
+class ContextExtTest {
+
+ @Test
+ fun hasNearbyWifiDevicesOrLocationPermission_usesConfiguredPermissionName() {
+ val wifiManager = mockk(relaxed = true)
+ val packageManager = mockk(relaxed = true)
+ val granted = testContext(
+ wifiManager = wifiManager,
+ packageManager = packageManager,
+ permissionResults = mapOf(NEARBY_WIFI_PERMISSION_NAME to PackageManager.PERMISSION_GRANTED)
+ )
+ val denied = testContext(
+ wifiManager = wifiManager,
+ packageManager = packageManager,
+ permissionResults = mapOf(NEARBY_WIFI_PERMISSION_NAME to PackageManager.PERMISSION_DENIED)
+ )
+
+ assertTrue(granted.hasNearbyWifiDevicesOrLocationPermission())
+ assertFalse(denied.hasNearbyWifiDevicesOrLocationPermission())
+ }
+
+ @Test
+ fun hasBluetoothConnectPermission_onApi31Plus_requiresPermission() {
+ val wifiManager = mockk(relaxed = true)
+ val packageManager = mockk(relaxed = true)
+ val granted = testContext(
+ wifiManager = wifiManager,
+ packageManager = packageManager,
+ permissionResults = mapOf(Manifest.permission.BLUETOOTH_CONNECT to PackageManager.PERMISSION_GRANTED)
+ )
+ val denied = testContext(
+ wifiManager = wifiManager,
+ packageManager = packageManager,
+ permissionResults = mapOf(Manifest.permission.BLUETOOTH_CONNECT to PackageManager.PERMISSION_DENIED)
+ )
+
+ assertTrue(granted.hasBluetoothConnectPermission())
+ assertFalse(denied.hasBluetoothConnectPermission())
+ }
+
+ @Test
+ @Config(sdk = [29], manifest = Config.NONE)
+ fun hasBluetoothConnectPermission_belowApi31_returnsTrue() {
+ val context = testContext(
+ wifiManager = mockk(relaxed = true),
+ packageManager = mockk(relaxed = true),
+ permissionResults = emptyMap()
+ )
+
+ assertTrue(context.hasBluetoothConnectPermission())
+ }
+
+ @Test
+ fun hasStaApConcurrency_onApi30Plus_usesWifiManagerCapability() {
+ val wifiManager = mockk(relaxed = true)
+ every { wifiManager.isStaApConcurrencySupported } returns true
+ val context = testContext(
+ wifiManager = wifiManager,
+ packageManager = mockk(relaxed = true),
+ permissionResults = emptyMap()
+ )
+
+ assertTrue(context.hasStaApConcurrency())
+ }
+
+ @Test
+ @Config(sdk = [29], manifest = Config.NONE)
+ fun hasStaApConcurrency_belowApi30_returnsFalse() {
+ val context = testContext(
+ wifiManager = mockk(relaxed = true),
+ packageManager = mockk(relaxed = true),
+ permissionResults = emptyMap()
+ )
+
+ assertFalse(context.hasStaApConcurrency())
+ }
+
+ @Test
+ fun deviceInfo_includesExpectedCapabilities() {
+ val wifiManager = mockk(relaxed = true)
+ val packageManager = mockk(relaxed = true)
+ every { wifiManager.is5GHzBandSupported } returns true
+ every { wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported } returns true
+ every { wifiManager.isStaApConcurrencySupported } returns false
+ every { packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE) } returns true
+ val context = testContext(
+ wifiManager = wifiManager,
+ packageManager = packageManager,
+ permissionResults = emptyMap()
+ )
+
+ val info = context.deviceInfo()
+
+ assertTrue(info.contains("5Ghz supported: true"))
+ assertTrue(info.contains("Local-only station concurrency: true"))
+ assertTrue(info.contains("Station-AP concurrency: false"))
+ assertTrue(info.contains("WifiAware support: true"))
+ }
+
+ private fun testContext(
+ wifiManager: WifiManager,
+ packageManager: PackageManager,
+ permissionResults: Map
+ ): Context {
+ return mockk(relaxed = true) {
+ every { getSystemService(WifiManager::class.java) } returns wifiManager
+ every { this@mockk.packageManager } returns packageManager
+ every { checkPermission(any(), any(), any()) } answers {
+ permissionResults[firstArg()] ?: PackageManager.PERMISSION_DENIED
+ }
+ }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt
new file mode 100644
index 000000000..e00d0f4ad
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt
@@ -0,0 +1,95 @@
+package com.greybox.projectmesh.extension
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * JVM-only tests for List.updateItem extension.
+ *
+ * Verifies:
+ * - first matching item is updated
+ * - only the first match is changed
+ * - lists with no match return the same instance
+ * - empty lists behave correctly
+ * - update function is not invoked when no match exists
+ */
+class ListExtensionTest {
+
+ // --------------------------------------
+ // First match: should update first match
+ // --------------------------------------
+ @Test
+ fun checkUpdateFirstItem() {
+ val list = listOf(1, 2, 3, 4)
+ val updated = list.updateItem(
+ condition = { it % 2 == 0 },
+ function = { it * 10 }
+ )
+
+ assertEquals(listOf(1, 20, 3, 4), updated)
+ }
+
+ // ---------------------------------------------------
+ // Only first matching item should be transformed once
+ // ---------------------------------------------------
+ @Test
+ fun checkOnlyFirstMatch() {
+ val list = listOf(2, 4, 6)
+ val updated = list.updateItem(
+ condition = { it % 2 == 0 },
+ function = { it * 10 }
+ )
+
+ assertEquals(listOf(20, 4, 6), updated)
+ }
+
+ // -------------------------------------------------
+ // No match: must return same list instance unchanged
+ // -------------------------------------------------
+ @Test
+ fun checkNoMatch() {
+ val list = listOf(1, 3, 5)
+ val updated = list.updateItem(
+ condition = { it % 2 == 0 },
+ function = { it * 10 }
+ )
+
+ assertSame(list, updated)
+ assertEquals(listOf(1, 3, 5), updated)
+ }
+
+ // ---------------------------
+ // Empty list stays unchanged
+ // ---------------------------
+ @Test
+ fun checkEmptyList() {
+ val list = emptyList()
+ val updated = list.updateItem(
+ condition = { true },
+ function = { it * 10 }
+ )
+
+ assertSame(list, updated)
+ assertTrue(updated.isEmpty())
+ }
+
+ // -------------------------------------------------------
+ // No match: function should not be executed even once
+ // -------------------------------------------------------
+ @Test
+ fun checkCallingFunctionNoMatch() {
+ val list = listOf(1, 3, 5)
+ var called = false
+
+ val updated = list.updateItem(
+ condition = { it % 2 == 0 },
+ function = {
+ called = true
+ it * 10
+ }
+ )
+
+ assertFalse(called)
+ assertSame(list, updated)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/extension/NetworkUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/NetworkUtilsTest.kt
new file mode 100644
index 000000000..52429ea14
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/extension/NetworkUtilsTest.kt
@@ -0,0 +1,34 @@
+package com.greybox.projectmesh.extension
+
+import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.net.InetAddress
+
+class NetworkUtilsTest {
+
+ @Test
+ fun getLocalIpFromDI_returnsHostAddressFromAndroidVirtualNode() {
+ // Arrange
+ val mockAddress = mockk()
+ every { mockAddress.hostAddress } returns "192.168.1.100"
+
+ val mockNode = mockk()
+ every { mockNode.address } returns mockAddress
+
+ val di = DI {
+ bind() with singleton { mockNode }
+ }
+
+ // Act
+ val result = getLocalIpFromDI(di)
+
+ // Assert
+ assertEquals("192.168.1.100", result)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/extension/WifiListItemTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/WifiListItemTest.kt
new file mode 100644
index 000000000..6242b05da
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/extension/WifiListItemTest.kt
@@ -0,0 +1,86 @@
+package com.greybox.projectmesh.extension
+
+import android.os.Looper
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.user.UserRepository
+import com.ustadmobile.meshrabiya.vnet.VirtualNode
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+
+@RunWith(RobolectricTestRunner::class)
+class WifiListItemTest {
+
+ @Before
+ fun setUp() {
+ val mockRepo = mockk(relaxed = true)
+
+ // Return null so WifiListItem falls back to "Unknown"
+ coEvery { mockRepo.getUserByIp(any()) } returns null
+
+ GlobalApp.GlobalUserRepo.userRepository = mockRepo
+ }
+
+ @Test
+ fun wifiListItem_composesWithoutCrashing_whenOnClickIsNull() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .get()
+
+ val wifiEntry = mockk(relaxed = true)
+ every { wifiEntry.hopCount } returns 2
+ every { wifiEntry.originatorMessage.pingTimeSum } returns 42
+
+ activity.setContent {
+ WifiListItem(
+ wifiAddress = 0xC0A80101.toInt(),
+ wifiEntry = wifiEntry,
+ onClick = null
+ )
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun wifiListItem_composesWithoutCrashing_whenOnClickIsProvided() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .get()
+
+ val wifiEntry = mockk(relaxed = true)
+ every { wifiEntry.hopCount } returns 1
+ every { wifiEntry.originatorMessage.pingTimeSum } returns 15
+
+ var clickedAddress: String? = null
+
+ activity.setContent {
+ WifiListItem(
+ wifiAddress = 0xC0A80101.toInt(),
+ wifiEntry = wifiEntry,
+ onClick = { clickedAddress = it }
+ )
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ assertNull(clickedAddress)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/ConversationDaoTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/ConversationDaoTest.kt
new file mode 100644
index 000000000..99b1c9fd8
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/ConversationDaoTest.kt
@@ -0,0 +1,199 @@
+package com.greybox.projectmesh.messaging.data.dao
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.db.MeshDatabase
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class ConversationDaoTest {
+
+ private lateinit var db: MeshDatabase
+ private lateinit var dao: ConversationDao
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ MeshDatabase::class.java
+ ).allowMainThreadQueries().build()
+
+ dao = db.conversationDao()
+ }
+
+ @After
+ fun tearDown() {
+ db.close()
+ }
+
+ @Test
+ fun insert_and_getById_and_getByUserUuid_work() = runTest {
+ val c = conversation(id = "c1", userUuid = "u1", userName = "Alice", time = 10L)
+
+ dao.insertConversation(c)
+
+ val byId = dao.getConversationById("c1")
+ val byUser = dao.getConversationByUserUuid("u1")
+
+ assertEquals(c, byId)
+ assertEquals(c, byUser)
+ }
+
+ @Test
+ fun getAllConversationsFlow_ordersByLastMessageTimeDesc() = runTest {
+ dao.insertConversation(conversation(id = "old", userUuid = "u-old", userName = "Old", time = 1L))
+ dao.insertConversation(conversation(id = "new", userUuid = "u-new", userName = "New", time = 100L))
+ dao.insertConversation(conversation(id = "mid", userUuid = "u-mid", userName = "Mid", time = 50L))
+
+ val list = dao.getAllConversationsFlow().first()
+
+ assertEquals(listOf("new", "mid", "old"), list.map { it.id })
+ }
+
+ @Test
+ fun updateConversation_replacesStoredValues() = runTest {
+ dao.insertConversation(conversation(id = "c2", userUuid = "u2", userName = "Bob", time = 10L))
+
+ dao.updateConversation(
+ conversation(
+ id = "c2",
+ userUuid = "u2",
+ userName = "Bobby",
+ userAddress = "10.0.0.22",
+ lastMessage = "updated",
+ time = 999L,
+ unreadCount = 4,
+ isOnline = true
+ )
+ )
+
+ val updated = dao.getConversationById("c2")
+ assertNotNull(updated)
+ assertEquals("Bobby", updated?.userName)
+ assertEquals("10.0.0.22", updated?.userAddress)
+ assertEquals("updated", updated?.lastMessage)
+ assertEquals(999L, updated?.lastMessageTime)
+ assertEquals(4, updated?.unreadCount)
+ assertEquals(true, updated?.isOnline)
+ }
+
+ @Test
+ fun updateUserConnectionStatus_updatesOnlyConnectionColumns() = runTest {
+ dao.insertConversation(
+ conversation(
+ id = "c3",
+ userUuid = "u3",
+ userName = "Carol",
+ userAddress = null,
+ lastMessage = "keep",
+ time = 30L,
+ unreadCount = 2,
+ isOnline = false
+ )
+ )
+
+ dao.updateUserConnectionStatus(userUuid = "u3", isOnline = true, userAddress = "10.0.0.3")
+
+ val updated = dao.getConversationByUserUuid("u3")
+ assertNotNull(updated)
+ assertEquals(true, updated?.isOnline)
+ assertEquals("10.0.0.3", updated?.userAddress)
+ assertEquals("keep", updated?.lastMessage)
+ assertEquals(30L, updated?.lastMessageTime)
+ assertEquals(2, updated?.unreadCount)
+ }
+
+ @Test
+ fun updateLastMessage_changesMessageAndTimestamp() = runTest {
+ dao.insertConversation(conversation(id = "c4", userUuid = "u4", userName = "Dave", time = 1L))
+
+ dao.updateLastMessage(conversationId = "c4", lastMessage = "hello world", timestamp = 1234L)
+
+ val updated = dao.getConversationById("c4")
+ assertEquals("hello world", updated?.lastMessage)
+ assertEquals(1234L, updated?.lastMessageTime)
+ }
+
+ @Test
+ fun incrementUnreadCount_and_clearUnreadCount_work() = runTest {
+ dao.insertConversation(conversation(id = "c5", userUuid = "u5", userName = "Eve", time = 1L))
+
+ dao.incrementUnreadCount("c5")
+ dao.incrementUnreadCount("c5")
+
+ val incremented = dao.getConversationById("c5")
+ assertEquals(2, incremented?.unreadCount)
+
+ dao.clearUnreadCount("c5")
+
+ val cleared = dao.getConversationById("c5")
+ assertEquals(0, cleared?.unreadCount)
+ }
+
+ @Test
+ fun insertConversation_withSameId_replacesRow() = runTest {
+ dao.insertConversation(conversation(id = "same", userUuid = "u6", userName = "First", time = 1L))
+ dao.insertConversation(
+ conversation(
+ id = "same",
+ userUuid = "u6",
+ userName = "Second",
+ userAddress = "10.1.1.6",
+ lastMessage = "new",
+ time = 2L,
+ unreadCount = 9,
+ isOnline = true
+ )
+ )
+
+ val row = dao.getConversationById("same")
+ assertNotNull(row)
+ assertEquals("Second", row?.userName)
+ assertEquals("10.1.1.6", row?.userAddress)
+ assertEquals("new", row?.lastMessage)
+ assertEquals(2L, row?.lastMessageTime)
+ assertEquals(9, row?.unreadCount)
+ assertEquals(true, row?.isOnline)
+ }
+
+ @Test
+ fun getConversationById_returnsNull_whenMissing() = runTest {
+ assertNull(dao.getConversationById("does-not-exist"))
+ }
+
+ private fun conversation(
+ id: String,
+ userUuid: String,
+ userName: String,
+ userAddress: String? = null,
+ lastMessage: String? = null,
+ time: Long,
+ unreadCount: Int = 0,
+ isOnline: Boolean = false
+ ): Conversation {
+ return Conversation(
+ id = id,
+ userUuid = userUuid,
+ userName = userName,
+ userAddress = userAddress,
+ lastMessage = lastMessage,
+ lastMessageTime = time,
+ unreadCount = unreadCount,
+ isOnline = isOnline
+ )
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/MessageDaoTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/MessageDaoTest.kt
new file mode 100644
index 000000000..70b0ae9df
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/dao/MessageDaoTest.kt
@@ -0,0 +1,159 @@
+package com.greybox.projectmesh.messaging.data.dao
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.db.MeshDatabase
+import com.greybox.projectmesh.messaging.data.entities.Message
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.URI
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class MessageDaoTest {
+
+ private lateinit var db: MeshDatabase
+ private lateinit var dao: MessageDao
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ MeshDatabase::class.java
+ ).allowMainThreadQueries().build()
+
+ dao = db.messageDao()
+ }
+
+ @After
+ fun tearDown() {
+ db.close()
+ }
+
+ @Test
+ fun addMessage_persistsRows_and_getAll_matchesStoredData() = runTest {
+ dao.addMessage(message(chat = "a", content = "m1", time = 10L))
+ dao.addMessage(message(chat = "b", content = "m2", time = 20L, file = URI.create("https://example.com/f")))
+
+ val rows = dao.getAll()
+
+ assertEquals(2, rows.size)
+ assertEquals(listOf("m1", "m2"), rows.map { it.content })
+ assertTrue(rows[0].id > 0)
+ assertTrue(rows[1].id > 0)
+ assertEquals("https://example.com/f", rows[1].file.toString())
+ }
+
+ @Test
+ fun getAllFlow_emitsAllMessages() = runTest {
+ dao.addMessage(message(chat = "a", content = "x", time = 1L))
+ dao.addMessage(message(chat = "b", content = "y", time = 2L))
+
+ val rows = dao.getAllFlow().first()
+
+ assertEquals(2, rows.size)
+ }
+
+ @Test
+ fun getChatMessagesFlow_returnsOnlyChat_andSortedAscendingByDate() = runTest {
+ dao.addMessage(message(chat = "c1", content = "late", time = 20L))
+ dao.addMessage(message(chat = "c2", content = "other", time = 5L))
+ dao.addMessage(message(chat = "c1", content = "early", time = 10L))
+
+ val rows = dao.getChatMessagesFlow("c1").first()
+
+ assertEquals(listOf("early", "late"), rows.map { it.content })
+ assertEquals(listOf(10L, 20L), rows.map { it.dateReceived })
+ assertTrue(rows.all { it.chat == "c1" })
+ }
+
+ @Test
+ fun getChatMessagesSync_returnsOnlyChat_andSortedAscendingByDate() = runTest {
+ dao.addMessage(message(chat = "sync", content = "second", time = 200L))
+ dao.addMessage(message(chat = "sync", content = "first", time = 100L))
+ dao.addMessage(message(chat = "other", content = "ignored", time = 50L))
+
+ val rows = dao.getChatMessagesSync("sync")
+
+ assertEquals(listOf("first", "second"), rows.map { it.content })
+ }
+
+ @Test
+ fun getChatMessagesFlowMultipleNames_filtersByList_andSortsAscending() = runTest {
+ dao.addMessage(message(chat = "a", content = "a2", time = 30L))
+ dao.addMessage(message(chat = "b", content = "b1", time = 10L))
+ dao.addMessage(message(chat = "c", content = "c1", time = 5L))
+ dao.addMessage(message(chat = "a", content = "a1", time = 20L))
+
+ val rows = dao.getChatMessagesFlowMultipleNames(listOf("a", "b")).first()
+
+ assertEquals(listOf("b1", "a1", "a2"), rows.map { it.content })
+ assertTrue(rows.all { it.chat == "a" || it.chat == "b" })
+ }
+
+ @Test
+ fun delete_removesSingleMessage() = runTest {
+ dao.addMessage(message(chat = "d", content = "keep", time = 1L))
+ dao.addMessage(message(chat = "d", content = "remove", time = 2L))
+
+ val existing = dao.getChatMessagesSync("d")
+ val toDelete = existing.first { it.content == "remove" }
+
+ dao.delete(toDelete)
+
+ val after = dao.getChatMessagesSync("d")
+ assertEquals(1, after.size)
+ assertEquals("keep", after.single().content)
+ }
+
+ @Test
+ fun deleteAll_removesProvidedMessages() = runTest {
+ dao.addMessage(message(chat = "e", content = "m1", time = 1L))
+ dao.addMessage(message(chat = "e", content = "m2", time = 2L))
+ dao.addMessage(message(chat = "e", content = "m3", time = 3L))
+
+ val rows = dao.getChatMessagesSync("e")
+ dao.deleteAll(rows.take(2))
+
+ val after = dao.getChatMessagesSync("e")
+ assertEquals(1, after.size)
+ assertEquals("m3", after.single().content)
+ }
+
+ @Test
+ fun clearTable_removesAllRows() = runTest {
+ dao.addMessage(message(chat = "x", content = "1", time = 1L))
+ dao.addMessage(message(chat = "y", content = "2", time = 2L))
+
+ dao.clearTable()
+
+ assertTrue(dao.getAll().isEmpty())
+ }
+
+ private fun message(
+ chat: String,
+ content: String,
+ time: Long,
+ sender: String = "sender",
+ file: URI? = null
+ ): Message {
+ return Message(
+ id = 0,
+ dateReceived = time,
+ content = content,
+ sender = sender,
+ chat = chat,
+ file = file
+ )
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/ConversationTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/ConversationTest.kt
new file mode 100644
index 000000000..2383050a9
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/ConversationTest.kt
@@ -0,0 +1,81 @@
+package com.greybox.projectmesh.messaging.data.entities
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ConversationTest {
+
+ @Test
+ fun constructor_defaults_areApplied() {
+ val conversation = Conversation(
+ id = "convo-1",
+ userUuid = "user-2",
+ userName = "Alice",
+ userAddress = null,
+ lastMessage = null,
+ lastMessageTime = 0L
+ )
+
+ assertEquals(0, conversation.unreadCount)
+ assertFalse(conversation.isOnline)
+ }
+
+ @Test
+ fun dataClass_copy_and_equality_behaveAsExpected() {
+ val original = Conversation(
+ id = "convo-2",
+ userUuid = "user-3",
+ userName = "Bob",
+ userAddress = "10.0.0.2",
+ lastMessage = "hi",
+ lastMessageTime = 10L,
+ unreadCount = 1,
+ isOnline = true
+ )
+
+ val copy = original.copy(unreadCount = 5)
+
+ assertEquals(original.id, copy.id)
+ assertEquals(original.userUuid, copy.userUuid)
+ assertEquals(original.userName, copy.userName)
+ assertEquals(5, copy.unreadCount)
+ assertTrue(copy.isOnline)
+ assertTrue(original != copy)
+ }
+
+ @Test
+ fun dataClass_destructuring_order_matchesConstructorOrder() {
+ val conversation = Conversation(
+ id = "convo-3",
+ userUuid = "u9",
+ userName = "Zed",
+ userAddress = "10.0.0.9",
+ lastMessage = "hey",
+ lastMessageTime = 42L,
+ unreadCount = 6,
+ isOnline = true
+ )
+
+ val (
+ id,
+ userUuid,
+ userName,
+ userAddress,
+ lastMessage,
+ lastMessageTime,
+ unreadCount,
+ isOnline
+ ) = conversation
+
+ assertEquals("convo-3", id)
+ assertEquals("u9", userUuid)
+ assertEquals("Zed", userName)
+ assertEquals("10.0.0.9", userAddress)
+ assertEquals("hey", lastMessage)
+ assertEquals(42L, lastMessageTime)
+ assertEquals(6, unreadCount)
+ assertTrue(isOnline)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt
new file mode 100644
index 000000000..1cef20f3c
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt
@@ -0,0 +1,49 @@
+package com.greybox.projectmesh.messaging.data.entities
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.File
+
+class FileEncoderTest {
+
+ private val encoder = FileEncoder()
+
+ @Test
+ fun encodeBytesBase64_encodesNonNullBytes() {
+ val original = "hello".toByteArray()
+ val result = encoder.encodeBytesBase64(original)
+ assertEquals("aGVsbG8=", result)
+ }
+
+ @Test
+ fun encodeBytesBase64_returnsErrorMessageForNullBytes() {
+ val result = encoder.encodeBytesBase64(null)
+ assertEquals("Cannot encode file", result)
+ }
+
+ @Test
+ fun decodeBase64_writesDecodedBytesToFile() {
+ val base64 = "aGVsbG8="
+ val tempFile = File.createTempFile("fileencoder_test", ".bin")
+ tempFile.deleteOnExit()
+
+ encoder.decodeBase64(base64, tempFile)
+
+ val content = tempFile.readBytes()
+ assertArrayEquals("hello".toByteArray(), content)
+ }
+
+ @Test
+ fun decodeBase64_overwritesExistingFileContent() {
+ val tempFile = File.createTempFile("fileencoder_overwrite_test", ".bin")
+ tempFile.writeText("old-content")
+ tempFile.deleteOnExit()
+
+ val base64 = "aGVsbG8="
+ encoder.decodeBase64(base64, tempFile)
+
+ val content = tempFile.readBytes()
+ assertArrayEquals("hello".toByteArray(), content)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/JSONSchemaTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/JSONSchemaTest.kt
new file mode 100644
index 000000000..8ed848938
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/JSONSchemaTest.kt
@@ -0,0 +1,82 @@
+package com.greybox.projectmesh.messaging.data.entities
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class JSONSchemaTest {
+
+ private val schema = JSONSchema()
+
+ @Test
+ fun schemaValidation_returnsTrue_forValidPayload() {
+ val json = """
+ {
+ "id": 1,
+ "chat": "convo-a",
+ "content": "hello",
+ "dateReceived": 12345,
+ "sender": "Alice",
+ "file": "https://example.com/file.txt"
+ }
+ """.trimIndent()
+
+ assertTrue(schema.schemaValidation(json))
+ }
+
+ @Test
+ fun schemaValidation_returnsTrue_whenOptionalFileMissing() {
+ val json = """
+ {
+ "id": 2,
+ "chat": "convo-b",
+ "content": "no file",
+ "dateReceived": 12346,
+ "sender": "Bob"
+ }
+ """.trimIndent()
+
+ assertTrue(schema.schemaValidation(json))
+ }
+
+ @Test
+ fun schemaValidation_returnsFalse_whenRequiredFieldMissing() {
+ val json = """
+ {
+ "id": 3,
+ "chat": "convo-c",
+ "content": "missing sender",
+ "dateReceived": 12347
+ }
+ """.trimIndent()
+
+ assertFalse(schema.schemaValidation(json))
+ }
+
+ @Test
+ fun schemaValidation_returnsFalse_forMalformedJson() {
+ val malformed = "{\"id\":1,\"chat\":\"x\",\"content\":\"y\","
+ assertFalse(schema.schemaValidation(malformed))
+ }
+
+ @Test
+ fun schemaValidation_currentlyDoesNotEnforceUriFormatOnFile() {
+ val json = """
+ {
+ "id": 4,
+ "chat": "convo-d",
+ "content": "bad file uri format",
+ "dateReceived": 12348,
+ "sender": "Carol",
+ "file": "not a uri"
+ }
+ """.trimIndent()
+
+ assertTrue(schema.schemaValidation(json))
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/MessageTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/MessageTest.kt
new file mode 100644
index 000000000..c9cea6cba
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/MessageTest.kt
@@ -0,0 +1,92 @@
+package com.greybox.projectmesh.messaging.data.entities
+
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.net.URI
+
+class MessageTest {
+
+ private val converter = URIConverter()
+
+ @Test
+ fun uriConverter_roundTripsNonNullUri() {
+ val uri = URI.create("https://example.com/doc.txt")
+
+ val asString = converter.convfromURI(uri)
+ val reconstructed = converter.convtoURI(asString)
+
+ assertEquals("https://example.com/doc.txt", asString)
+ assertEquals(uri, reconstructed)
+ }
+
+ @Test
+ fun uriConverter_handlesNulls() {
+ assertNull(converter.convfromURI(null))
+ assertNull(converter.convtoURI(null))
+ }
+
+ @Test
+ fun uriSerializable_serializesAndDeserializesUri() {
+ val uri = URI.create("file:///tmp/a.txt")
+
+ val encoded = Json.encodeToString(URISerializable, uri)
+ val decoded = Json.decodeFromString(URISerializable, encoded)
+
+ assertEquals("\"file:///tmp/a.txt\"", encoded)
+ assertEquals(uri, decoded)
+ }
+
+ @Test
+ fun message_serialization_usesUriSerializer_andPreservesFields() {
+ val message = Message(
+ id = 7,
+ dateReceived = 456L,
+ content = "payload",
+ sender = "Alice",
+ chat = "convo-1",
+ file = URI.create("https://example.com/f.png")
+ )
+
+ val encoded = Json.encodeToString(Message.serializer(), message)
+ val decoded = Json.decodeFromString(Message.serializer(), encoded)
+
+ assertTrue(encoded.contains("\"file\":\"https://example.com/f.png\""))
+ assertEquals(message, decoded)
+ }
+
+ @Test
+ fun message_defaults_fileToNull() {
+ val message = Message(
+ id = 8,
+ dateReceived = 789L,
+ content = "no attachment",
+ sender = "Bob",
+ chat = "convo-2"
+ )
+
+ assertNull(message.file)
+ }
+
+ @Test
+ fun uriConverter_throwsForInvalidUriString() {
+ try {
+ converter.convtoURI("http://bad uri")
+ } catch (expected: IllegalArgumentException) {
+ return
+ }
+ throw AssertionError("Expected IllegalArgumentException for invalid URI string")
+ }
+
+ @Test
+ fun uriSerializable_throwsForInvalidDecodedUri() {
+ try {
+ Json.decodeFromString(URISerializable, "\"http://bad uri\"")
+ } catch (expected: IllegalArgumentException) {
+ return
+ }
+ throw AssertionError("Expected IllegalArgumentException for invalid URI string")
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandlerTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandlerTest.kt
new file mode 100644
index 000000000..70b6dc1a9
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandlerTest.kt
@@ -0,0 +1,249 @@
+package com.greybox.projectmesh.messaging.network
+
+import android.content.SharedPreferences
+import android.util.Log
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.repository.ConversationRepository
+import com.greybox.projectmesh.messaging.utils.ConversationUtils
+import com.greybox.projectmesh.user.UserEntity
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import okhttp3.Call
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.net.InetAddress
+import java.net.URI
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class MessageNetworkHandlerTest {
+
+ private lateinit var httpClient: OkHttpClient
+ private lateinit var call: Call
+ private lateinit var localAddr: InetAddress
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+
+ httpClient = mockk(relaxed = true)
+ call = mockk(relaxed = true)
+ localAddr = InetAddress.getByName("10.0.0.100")
+ }
+
+ @After
+ fun tearDown() {
+ io.mockk.unmockkStatic(Log::class)
+ }
+
+ @Test
+ fun sendChatMessage_buildsExpectedRequest_withoutFile_andExecutesCall() {
+ val requestSlot = io.mockk.slot()
+ val latch = CountDownLatch(1)
+
+ every { httpClient.newCall(capture(requestSlot)) } returns call
+ every { call.execute() } answers {
+ latch.countDown()
+ Response.Builder()
+ .request(requestSlot.captured)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body("".toResponseBody(null))
+ .build()
+ }
+
+ val di = DI {
+ bind() with singleton { mockk(relaxed = true) }
+ bind(tag = "settings") with singleton { mockk(relaxed = true) }
+ }
+ val handler = MessageNetworkHandler(httpClient, localAddr, di)
+
+ handler.sendChatMessage(
+ address = InetAddress.getByName("10.0.0.50"),
+ time = 123L,
+ message = "hello",
+ file = null
+ )
+
+ assertTrue("Expected network call to execute", latch.await(2, TimeUnit.SECONDS))
+
+ val url = requestSlot.captured.url
+ assertEquals("http", url.scheme)
+ assertEquals("10.0.0.50", url.host)
+ assertEquals("/chat", url.encodedPath)
+ assertEquals("hello", url.queryParameter("chatMessage"))
+ assertEquals("123", url.queryParameter("time"))
+ assertEquals("10.0.0.100", url.queryParameter("senderIp"))
+ assertNull(url.queryParameter("incomingfile"))
+ }
+
+ @Test
+ fun sendChatMessage_includesIncomingFileQuery_whenFileProvided() {
+ val requestSlot = io.mockk.slot()
+ val latch = CountDownLatch(1)
+
+ every { httpClient.newCall(capture(requestSlot)) } returns call
+ every { call.execute() } answers {
+ latch.countDown()
+ Response.Builder()
+ .request(requestSlot.captured)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body("".toResponseBody(null))
+ .build()
+ }
+
+ val di = DI {
+ bind() with singleton { mockk(relaxed = true) }
+ bind(tag = "settings") with singleton { mockk(relaxed = true) }
+ }
+ val handler = MessageNetworkHandler(httpClient, localAddr, di)
+
+ val fileUri = URI.create("https://example.com/file.txt")
+ handler.sendChatMessage(
+ address = InetAddress.getByName("10.0.0.51"),
+ time = 456L,
+ message = "with file",
+ file = fileUri
+ )
+
+ assertTrue("Expected network call to execute", latch.await(2, TimeUnit.SECONDS))
+ assertEquals(fileUri.toString(), requestSlot.captured.url.queryParameter("incomingfile"))
+ }
+
+ @Test
+ fun sendChatMessage_whenExecuteThrows_isHandledWithoutCrash() {
+ val requestSlot = io.mockk.slot()
+ val latch = CountDownLatch(1)
+
+ every { httpClient.newCall(capture(requestSlot)) } returns call
+ every { call.execute() } answers {
+ latch.countDown()
+ throw RuntimeException("boom")
+ }
+
+ val di = DI {
+ bind() with singleton { mockk(relaxed = true) }
+ bind(tag = "settings") with singleton { mockk(relaxed = true) }
+ }
+ val handler = MessageNetworkHandler(httpClient, localAddr, di)
+
+ handler.sendChatMessage(
+ address = InetAddress.getByName("10.0.0.52"),
+ time = 789L,
+ message = "error case",
+ file = null
+ )
+
+ assertTrue("Expected attempted network call", latch.await(2, TimeUnit.SECONDS))
+ }
+
+ @Test
+ fun handleIncomingMessage_withUser_updatesConversation_andReturnsMappedMessage() {
+ val userRepo = mockk()
+ val conversationRepo = mockk(relaxed = true)
+ val prefs = mockk()
+
+ val senderIp = InetAddress.getByName("10.10.10.2")
+ val senderIpStr = senderIp.hostAddress ?: error("senderIp.hostAddress was null")
+ val user = UserEntity(uuid = "remote-1", name = "Alice", address = senderIpStr)
+
+ coEvery { userRepo.getUserByIp(senderIpStr) } returns user
+ every { prefs.getString("UUID", null) } returns "local-uuid"
+ coEvery {
+ conversationRepo.getOrCreateConversation("local-uuid", user)
+ } returns Conversation(
+ id = ConversationUtils.createConversationId("local-uuid", "remote-1"),
+ userUuid = "remote-1",
+ userName = "Alice",
+ userAddress = senderIpStr,
+ lastMessage = null,
+ lastMessageTime = 0L
+ )
+
+ GlobalApp.GlobalUserRepo.userRepository = userRepo
+ GlobalApp.GlobalUserRepo.conversationRepository = conversationRepo
+ GlobalApp.GlobalUserRepo.prefs = prefs
+
+ val msg = MessageNetworkHandler.handleIncomingMessage(
+ chatMessage = "hi there",
+ time = 500L,
+ senderIp = senderIp,
+ incomingfile = URI.create("file:///tmp/a.txt")
+ )
+
+ assertEquals("hi there", msg.content)
+ assertEquals("Alice", msg.sender)
+ assertEquals(
+ ConversationUtils.createConversationId("local-uuid", "remote-1"),
+ msg.chat
+ )
+ assertEquals("file:///tmp/a.txt", msg.file.toString())
+
+ coVerify(exactly = 1) { userRepo.getUserByIp(senderIpStr) }
+ coVerify(exactly = 1) { conversationRepo.getOrCreateConversation("local-uuid", user) }
+ coVerify(exactly = 1) {
+ conversationRepo.updateWithMessage(
+ conversationId = ConversationUtils.createConversationId("local-uuid", "remote-1"),
+ message = msg
+ )
+ }
+ }
+
+ @Test
+ fun handleIncomingMessage_withoutUser_usesUnknownSender_andSkipsConversationUpdate() {
+ val userRepo = mockk()
+ val conversationRepo = mockk(relaxed = true)
+ val prefs = mockk()
+
+ val senderIp = InetAddress.getByName("10.10.10.3")
+ val senderIpStr = senderIp.hostAddress ?: error("senderIp.hostAddress was null")
+
+ coEvery { userRepo.getUserByIp(senderIpStr) } returns null
+ every { prefs.getString("UUID", null) } returns "local-uuid"
+
+ GlobalApp.GlobalUserRepo.userRepository = userRepo
+ GlobalApp.GlobalUserRepo.conversationRepository = conversationRepo
+ GlobalApp.GlobalUserRepo.prefs = prefs
+
+ val msg = MessageNetworkHandler.handleIncomingMessage(
+ chatMessage = null,
+ time = 501L,
+ senderIp = senderIp,
+ incomingfile = null
+ )
+
+ assertEquals("Error! No message found.", msg.content)
+ assertEquals("Unknown", msg.sender)
+ assertEquals(
+ ConversationUtils.createConversationId("local-uuid", "unknown-$senderIpStr"),
+ msg.chat
+ )
+
+ coVerify(exactly = 0) { conversationRepo.getOrCreateConversation(any(), any()) }
+ coVerify(exactly = 0) { conversationRepo.updateWithMessage(any(), any()) }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageServiceTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageServiceTest.kt
new file mode 100644
index 000000000..d876d83c9
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/network/MessageServiceTest.kt
@@ -0,0 +1,120 @@
+package com.greybox.projectmesh.messaging.network
+
+import android.content.SharedPreferences
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.repository.ConversationRepository
+import com.greybox.projectmesh.messaging.repository.MessageRepository
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.net.InetAddress
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MessageServiceTest {
+
+ private lateinit var networkHandler: MessageNetworkHandler
+ private lateinit var messageRepository: MessageRepository
+ private lateinit var conversationRepository: ConversationRepository
+ private lateinit var userRepository: UserRepository
+ private lateinit var settingsPrefs: SharedPreferences
+ private lateinit var service: MessageService
+
+ @Before
+ fun setUp() {
+ networkHandler = mockk(relaxed = true)
+ messageRepository = mockk(relaxed = true)
+ conversationRepository = mockk(relaxed = true)
+ userRepository = mockk(relaxed = true)
+ settingsPrefs = mockk(relaxed = true)
+
+ val di = DI {
+ bind() with singleton { networkHandler }
+ bind() with singleton { messageRepository }
+ bind() with singleton { conversationRepository }
+ bind() with singleton { userRepository }
+ bind(tag = "settings") with singleton { settingsPrefs }
+ }
+
+ service = MessageService(di)
+ }
+
+ @Test
+ fun sendMessage_savesFirst_thenSendsOverNetwork_withNullFile() = runTest {
+ val addr = InetAddress.getByName("10.0.0.5")
+ val msg = Message(
+ id = 0,
+ dateReceived = 12345L,
+ content = "hello",
+ sender = "Me",
+ chat = "chat-1",
+ file = null
+ )
+
+ service.sendMessage(addr, msg)
+
+ coVerifyOrder {
+ messageRepository.addMessage(msg)
+ networkHandler.sendChatMessage(
+ address = addr,
+ time = msg.dateReceived,
+ message = msg.content,
+ file = null
+ )
+ }
+ }
+
+ @Test
+ fun sendMessage_whenRepositoryThrows_propagates_andDoesNotSend() = runTest {
+ val addr = InetAddress.getByName("10.0.0.6")
+ val msg = Message(0, 1L, "x", "Me", "chat-x", null)
+
+ coEvery { messageRepository.addMessage(msg) } throws RuntimeException("db fail")
+
+ try {
+ service.sendMessage(addr, msg)
+ fail("Expected RuntimeException")
+ } catch (e: RuntimeException) {
+ assertEquals("db fail", e.message)
+ }
+
+ coVerify(exactly = 0) {
+ networkHandler.sendChatMessage(any(), any(), any(), any())
+ }
+ }
+
+ @Test
+ fun sendMessage_whenNetworkThrows_propagates_afterSave() = runTest {
+ val addr = InetAddress.getByName("10.0.0.7")
+ val msg = Message(0, 2L, "y", "Me", "chat-y", null)
+
+ every {
+ networkHandler.sendChatMessage(
+ address = addr,
+ time = msg.dateReceived,
+ message = msg.content,
+ file = null
+ )
+ } throws RuntimeException("network fail")
+
+ try {
+ service.sendMessage(addr, msg)
+ fail("Expected RuntimeException")
+ } catch (e: RuntimeException) {
+ assertEquals("network fail", e.message)
+ }
+
+ coVerify(exactly = 1) { messageRepository.addMessage(msg) }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/repository/ConversationRepositoryTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/repository/ConversationRepositoryTest.kt
new file mode 100644
index 000000000..26d24f6de
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/repository/ConversationRepositoryTest.kt
@@ -0,0 +1,230 @@
+package com.greybox.projectmesh.messaging.repository
+
+import com.greybox.projectmesh.messaging.data.dao.ConversationDao
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.utils.ConversationUtils
+import com.greybox.projectmesh.user.UserEntity
+import io.mockk.Called
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.clearAllMocks
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import java.net.URI
+import android.util.Log
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ConversationRepositoryTest {
+
+ private lateinit var dao: ConversationDao
+ private lateinit var repo: ConversationRepository
+ private val di = DI {}
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+
+ dao = mockk(relaxed = true)
+ repo = ConversationRepository(dao, di)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Log::class)
+ clearAllMocks()
+ }
+
+ @Test
+ fun getConversationById_delegatesToDao_andReturnsResult() = runTest {
+ val id = "abc"
+ val expected = Conversation(
+ id = id,
+ userUuid = "u2",
+ userName = "Bob",
+ userAddress = "10.0.0.2",
+ lastMessage = "hi",
+ lastMessageTime = 123L,
+ unreadCount = 0,
+ isOnline = true
+ )
+
+ coEvery { dao.getConversationById(id) } returns expected
+
+ val actual = repo.getConversationById(id)
+
+ assertEquals(expected, actual)
+ coVerify(exactly = 1) { dao.getConversationById(id) }
+ }
+
+ @Test
+ fun getConversationById_whenDaoReturnsNull_returnsNull() = runTest {
+ val id = "missing"
+ coEvery { dao.getConversationById(id) } returns null
+
+ val actual = repo.getConversationById(id)
+
+ assertNull(actual)
+ coVerify(exactly = 1) { dao.getConversationById(id) }
+ }
+
+ @Test
+ fun getOrCreateConversation_whenMissing_createsAndInsertsConversation() = runTest {
+ val localUuid = "local-1"
+ val remote = UserEntity(uuid = "remote-1", name = "Alice", address = "10.0.0.10", lastSeen = null)
+
+ val expectedId = ConversationUtils.createConversationId(localUuid, remote.uuid)
+
+ coEvery { dao.getConversationById(expectedId) } returns null
+ coEvery { dao.insertConversation(any()) } returns Unit
+
+ val before = System.currentTimeMillis()
+ val result = repo.getOrCreateConversation(localUuid, remote)
+ val after = System.currentTimeMillis()
+
+ assertEquals(expectedId, result.id)
+ assertEquals(remote.uuid, result.userUuid)
+ assertEquals(remote.name, result.userName)
+ assertEquals(remote.address, result.userAddress)
+ assertNull(result.lastMessage)
+ assertEquals(0, result.unreadCount)
+ assertTrue(result.isOnline)
+
+ assertTrue(result.lastMessageTime in before..after)
+
+ coVerify(exactly = 1) { dao.getConversationById(expectedId) }
+ coVerify(exactly = 1) {
+ dao.insertConversation(match {
+ it.id == expectedId &&
+ it.userUuid == remote.uuid &&
+ it.userName == remote.name &&
+ it.userAddress == remote.address &&
+ it.lastMessage == null &&
+ it.unreadCount == 0 &&
+ it.isOnline == true
+ })
+ }
+ }
+
+ @Test
+ fun getOrCreateConversation_whenExists_doesNotInsert_andReturnsExisting() = runTest {
+ val localUuid = "local-2"
+ val remote = UserEntity(uuid = "remote-2", name = "Eve", address = null, lastSeen = null)
+
+ val id = ConversationUtils.createConversationId(localUuid, remote.uuid)
+ val existing = Conversation(
+ id = id,
+ userUuid = remote.uuid,
+ userName = remote.name,
+ userAddress = null,
+ lastMessage = "old",
+ lastMessageTime = 999L,
+ unreadCount = 5,
+ isOnline = false
+ )
+
+ coEvery { dao.getConversationById(id) } returns existing
+
+ val result = repo.getOrCreateConversation(localUuid, remote)
+
+ assertEquals(existing, result)
+ coVerify(exactly = 1) { dao.getConversationById(id) }
+ coVerify(exactly = 0) { dao.insertConversation(any()) }
+ }
+
+ @Test
+ fun updateWithMessage_callsUpdateLastMessageTwice_andIncrementsUnread_ifSenderNotMe() = runTest {
+ val convoId = "c1"
+ val msg = Message(
+ id = 1,
+ dateReceived = 444L,
+ content = "hello",
+ sender = "Alice",
+ chat = convoId,
+ file = null
+ )
+
+ repo.updateWithMessage(convoId, msg)
+
+ coVerify(exactly = 2) {
+ dao.updateLastMessage(
+ conversationId = convoId,
+ lastMessage = msg.content,
+ timestamp = msg.dateReceived
+ )
+ }
+ coVerify(exactly = 1) { dao.incrementUnreadCount(convoId) }
+ }
+
+ @Test
+ fun updateWithMessage_doesNotIncrementUnread_ifSenderIsMe() = runTest {
+ val convoId = "c2"
+ val msg = Message(
+ id = 2,
+ dateReceived = 555L,
+ content = "sent by me",
+ sender = "Me",
+ chat = convoId,
+ file = URI.create("file://example")
+ )
+
+ repo.updateWithMessage(convoId, msg)
+
+ coVerify(exactly = 2) {
+ dao.updateLastMessage(
+ conversationId = convoId,
+ lastMessage = msg.content,
+ timestamp = msg.dateReceived
+ )
+ }
+ coVerify(exactly = 0) { dao.incrementUnreadCount(convoId) }
+ }
+
+ @Test
+ fun markAsRead_clearsUnreadCount() = runTest {
+ val convoId = "c3"
+
+ repo.markAsRead(convoId)
+
+ coVerify(exactly = 1) { dao.clearUnreadCount(convoId) }
+ }
+
+ @Test
+ fun updateUserStatus_callsDao_andDoesNotThrow() = runTest {
+ repo.updateUserStatus(userUuid = "u1", isOnline = true, userAddress = "10.0.0.9")
+
+ coVerify(exactly = 1) {
+ dao.updateUserConnectionStatus(userUuid = "u1", isOnline = true, userAddress = "10.0.0.9")
+ }
+ }
+
+ @Test
+ fun updateUserStatus_whenDaoThrows_exceptionIsCaught() = runTest {
+ coEvery {
+ dao.updateUserConnectionStatus(any(), any(), any())
+ } throws RuntimeException("db error")
+
+ repo.updateUserStatus(userUuid = "u2", isOnline = false, userAddress = null)
+
+ coVerify(exactly = 1) {
+ dao.updateUserConnectionStatus(userUuid = "u2", isOnline = false, userAddress = null)
+ }
+
+ assertNotNull("reached end without throwing", Unit)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/repository/MessageRepositoryTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/repository/MessageRepositoryTest.kt
new file mode 100644
index 000000000..1897a551d
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/repository/MessageRepositoryTest.kt
@@ -0,0 +1,71 @@
+package com.greybox.projectmesh.messaging.repository
+
+import com.greybox.projectmesh.messaging.data.dao.MessageDao
+import com.greybox.projectmesh.messaging.data.entities.Message
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import java.net.URI
+import kotlinx.coroutines.flow.first
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MessageRepositoryTest {
+
+ private lateinit var dao: MessageDao
+ private lateinit var repo: MessageRepository
+ private val di = DI {}
+
+ @Before
+ fun setUp() {
+ dao = mockk(relaxed = true)
+ repo = MessageRepository(dao, di)
+ }
+
+ @Test
+ fun getChatMessages_delegatesToDao() = runTest {
+ val chatId = "chat-1"
+ val list = listOf(
+ Message(1, 10L, "a", "Alice", chatId, null),
+ Message(2, 20L, "b", "Me", chatId, URI.create("file://x"))
+ )
+ every { dao.getChatMessagesFlow(chatId) } returns flowOf(list)
+
+ val actual = repo.getChatMessages(chatId).first()
+
+ assertEquals(list, actual)
+ verify(exactly = 1) { dao.getChatMessagesFlow(chatId) }
+ }
+
+ @Test
+ fun getAllMessages_delegatesToDao() = runTest {
+ every { dao.getAllFlow() } returns flowOf(emptyList())
+
+ repo.getAllMessages()
+
+ verify(exactly = 1) { dao.getAllFlow() }
+ }
+
+ @Test
+ fun addMessage_callsDaoAddMessage() = runTest {
+ val msg = Message(0, 123L, "hello", "Alice", "chat-2", null)
+
+ repo.addMessage(msg)
+
+ coVerify(exactly = 1) { dao.addMessage(msg) }
+ }
+
+ @Test
+ fun clearMessages_callsDaoClearTable() = runTest {
+ repo.clearMessages()
+
+ verify(exactly = 1) { dao.clearTable() }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModelTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModelTest.kt
new file mode 100644
index 000000000..f371642e4
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModelTest.kt
@@ -0,0 +1,85 @@
+package com.greybox.projectmesh.messaging.ui.models
+
+import com.greybox.projectmesh.messaging.data.entities.Message
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.net.InetAddress
+
+class ChatScreenModelTest {
+
+ @Test
+ fun chatScreenModel_usesDefaultValues_whenConstructedWithoutArguments() {
+ val model = ChatScreenModel()
+
+ assertNull(model.deviceName)
+ assertEquals(InetAddress.getByName("192.168.0.1"), model.virtualAddress)
+ assertEquals(emptyList(), model.allChatMessages)
+ assertNull(model.offlineWarning)
+ }
+
+ @Test
+ fun chatScreenModel_setsProvidedValues_correctly() {
+ val virtualAddress = InetAddress.getByName("10.0.0.25")
+ val messages = listOf(
+ mockk(),
+ mockk()
+ )
+
+ val model = ChatScreenModel(
+ deviceName = "Pixel 8",
+ virtualAddress = virtualAddress,
+ allChatMessages = messages,
+ offlineWarning = "Device is offline"
+ )
+
+ assertEquals("Pixel 8", model.deviceName)
+ assertEquals(virtualAddress, model.virtualAddress)
+ assertEquals(messages, model.allChatMessages)
+ assertEquals("Device is offline", model.offlineWarning)
+ }
+
+ @Test
+ fun chatScreenModel_supportsCopy_withUpdatedFields() {
+ val original = ChatScreenModel(
+ deviceName = "Old Device",
+ virtualAddress = InetAddress.getByName("192.168.1.5"),
+ allChatMessages = listOf(mockk()),
+ offlineWarning = null
+ )
+
+ val updated = original.copy(
+ deviceName = "New Device",
+ offlineWarning = "No connection"
+ )
+
+ assertEquals("New Device", updated.deviceName)
+ assertEquals(InetAddress.getByName("192.168.1.5"), updated.virtualAddress)
+ assertEquals(1, updated.allChatMessages.size)
+ assertEquals("No connection", updated.offlineWarning)
+ }
+
+ @Test
+ fun chatScreenModel_dataClassEquality_worksForSameValues() {
+ val address = InetAddress.getByName("172.16.0.3")
+ val messages = listOf(mockk())
+
+ val model1 = ChatScreenModel(
+ deviceName = "Device A",
+ virtualAddress = address,
+ allChatMessages = messages,
+ offlineWarning = "Offline"
+ )
+
+ val model2 = ChatScreenModel(
+ deviceName = "Device A",
+ virtualAddress = address,
+ allChatMessages = messages,
+ offlineWarning = "Offline"
+ )
+
+ assertEquals(model1, model2)
+ assertEquals(model1.hashCode(), model2.hashCode())
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModelTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModelTest.kt
new file mode 100644
index 000000000..18111bd3c
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModelTest.kt
@@ -0,0 +1,75 @@
+package com.greybox.projectmesh.messaging.ui.models
+
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import io.mockk.mockk
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ConversationsHomeScreenModelTest {
+
+ @Test
+ fun conversationsHomeScreenModel_usesDefaultValues() {
+ val model = ConversationsHomeScreenModel()
+
+ assertEquals(false, model.isLoading)
+ assertEquals(emptyList(), model.conversations)
+ assertNull(model.error)
+ }
+
+ @Test
+ fun conversationsHomeScreenModel_setsValuesCorrectly() {
+ val conversations = listOf(
+ mockk(),
+ mockk()
+ )
+
+ val model = ConversationsHomeScreenModel(
+ isLoading = true,
+ conversations = conversations,
+ error = "Something went wrong"
+ )
+
+ assertEquals(true, model.isLoading)
+ assertEquals(conversations, model.conversations)
+ assertEquals("Something went wrong", model.error)
+ }
+
+ @Test
+ fun conversationsHomeScreenModel_copyUpdatesFields() {
+ val original = ConversationsHomeScreenModel(
+ isLoading = true,
+ conversations = listOf(mockk()),
+ error = null
+ )
+
+ val updated = original.copy(
+ isLoading = false,
+ error = "Error occurred"
+ )
+
+ assertEquals(false, updated.isLoading)
+ assertEquals(1, updated.conversations.size)
+ assertEquals("Error occurred", updated.error)
+ }
+
+ @Test
+ fun conversationsHomeScreenModel_equalityWorks() {
+ val conversations = listOf(mockk())
+
+ val model1 = ConversationsHomeScreenModel(
+ isLoading = true,
+ conversations = conversations,
+ error = "Error"
+ )
+
+ val model2 = ConversationsHomeScreenModel(
+ isLoading = true,
+ conversations = conversations,
+ error = "Error"
+ )
+
+ assertEquals(model1, model2)
+ assertEquals(model1.hashCode(), model2.hashCode())
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreenTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreenTest.kt
new file mode 100644
index 000000000..cda81f539
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreenTest.kt
@@ -0,0 +1,83 @@
+package com.greybox.projectmesh.messaging.ui.screens
+
+import android.os.Build
+import android.os.Looper
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.greybox.projectmesh.viewModel.NetworkScreenModel
+import com.greybox.projectmesh.viewModel.NetworkScreenViewModel
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P])
+@LooperMode(LooperMode.Mode.PAUSED)
+class ChatNodeListScreenTest {
+
+ @Test
+ fun chatNodeListScreen_composesWithoutCrashing() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockViewModel = mockk(relaxed = true)
+ val stateFlow = MutableStateFlow(NetworkScreenModel(allNodes = emptyMap()))
+ every { mockViewModel.uiState } returns stateFlow
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ChatNodeListScreen(
+ onNodeSelected = {},
+ viewModel = mockViewModel
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun chatNodeListScreen_handlesNodeClickLambda_withoutCrash() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockViewModel = mockk(relaxed = true)
+ val stateFlow = MutableStateFlow(NetworkScreenModel(allNodes = emptyMap()))
+ every { mockViewModel.uiState } returns stateFlow
+
+ var selectedNode: String? = null
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ChatNodeListScreen(
+ onNodeSelected = { selectedNode = it },
+ viewModel = mockViewModel
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ assertNull(selectedNode)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreenTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreenTest.kt
new file mode 100644
index 000000000..0a6869769
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreenTest.kt
@@ -0,0 +1,177 @@
+package com.greybox.projectmesh.messaging.ui.screens
+
+import android.os.Build
+import android.os.Looper
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.ui.Modifier
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.ui.models.ChatScreenModel
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import java.net.InetAddress
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P])
+@LooperMode(LooperMode.Mode.PAUSED)
+class ChatScreenTest {
+
+ @Test
+ fun userStatusBar_composesWithoutCrashing_whenOnline() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ activity.runOnUiThread {
+ activity.setContent {
+ UserStatusBar(
+ userName = "Alice",
+ isOnline = true,
+ userAddress = "192.168.1.10"
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun userStatusBar_composesWithoutCrashing_whenOffline() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ activity.runOnUiThread {
+ activity.setContent {
+ UserStatusBar(
+ userName = "Bob",
+ isOnline = false,
+ userAddress = "192.168.1.20"
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun displayAllMessages_composesWithoutCrashing_whenNoMessages() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val uiState = ChatScreenModel(
+ deviceName = "Bob",
+ virtualAddress = InetAddress.getByName("192.168.1.20"),
+ allChatMessages = emptyList(),
+ offlineWarning = "Offline"
+ )
+
+ activity.runOnUiThread {
+ activity.setContent {
+ DisplayAllMessages(
+ uiState = uiState,
+ onClickButton = {}
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun displayAllMessages_composesWithoutCrashing_whenMessagesExist() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockUserRepository = mockk(relaxed = true)
+ coEvery { mockUserRepository.getUserByIp(any()) } returns null
+ GlobalApp.GlobalUserRepo.userRepository = mockUserRepository
+
+ val mockMessage = mockk(relaxed = true)
+ every { mockMessage.sender } returns "Peer"
+ every { mockMessage.content } returns "Hello"
+ every { mockMessage.file } returns null
+ every { mockMessage.dateReceived } returns 1710000000000L
+
+ val uiState = ChatScreenModel(
+ deviceName = "Bob",
+ virtualAddress = InetAddress.getByName("192.168.1.20"),
+ allChatMessages = listOf(mockMessage),
+ offlineWarning = null
+ )
+
+ activity.runOnUiThread {
+ activity.setContent {
+ DisplayAllMessages(
+ uiState = uiState,
+ onClickButton = {}
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun messageBubble_composesWithoutCrashing_withMockMessage() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockMessage = mockk(relaxed = true)
+ every { mockMessage.file } returns null
+ every { mockMessage.dateReceived } returns 1710000000000L
+
+ activity.runOnUiThread {
+ activity.setContent {
+ MessageBubble(
+ chatMessage = mockMessage,
+ sentBySelf = true,
+ sender = "Me",
+ modifier = Modifier,
+ messageContent = {}
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreenTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreenTest.kt
new file mode 100644
index 000000000..ff62e2ea8
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreenTest.kt
@@ -0,0 +1,211 @@
+package com.greybox.projectmesh.messaging.ui.screens
+
+import android.os.Build
+import android.os.Looper
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import com.greybox.projectmesh.messaging.ui.models.ConversationsHomeScreenModel
+import com.greybox.projectmesh.messaging.ui.viewmodels.ConversationsHomeScreenViewModel
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P])
+@LooperMode(LooperMode.Mode.PAUSED)
+class ConversationsHomeScreenTest {
+
+ @Test
+ fun conversationsHomeScreen_composesWithoutCrashing_whenErrorExists() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockViewModel = mockk(relaxed = true)
+ val stateFlow = MutableStateFlow(
+ ConversationsHomeScreenModel(
+ isLoading = false,
+ conversations = emptyList(),
+ error = "Unable to load"
+ )
+ )
+
+ every { mockViewModel.uiState } returns stateFlow
+ every { mockViewModel.refreshConversations() } just runs
+ every { mockViewModel.markConversationAsRead(any()) } just runs
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ConversationsHomeScreen(
+ onConversationSelected = {},
+ viewModel = mockViewModel
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun conversationsHomeScreen_composesWithoutCrashing_whenEmpty() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val mockViewModel = mockk(relaxed = true)
+ val stateFlow = MutableStateFlow(
+ ConversationsHomeScreenModel(
+ isLoading = false,
+ conversations = emptyList(),
+ error = null
+ )
+ )
+
+ every { mockViewModel.uiState } returns stateFlow
+ every { mockViewModel.refreshConversations() } just runs
+ every { mockViewModel.markConversationAsRead(any()) } just runs
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ConversationsHomeScreen(
+ onConversationSelected = {},
+ viewModel = mockViewModel
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun conversationsList_composesWithoutCrashing_withConversationItems() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val conversation = mockk(relaxed = true)
+ every { conversation.id } returns "conv-1"
+ every { conversation.userName } returns "Alice"
+ every { conversation.isOnline } returns true
+ every { conversation.userAddress } returns "192.168.1.10"
+ every { conversation.lastMessage } returns "Hello there"
+ every { conversation.lastMessageTime } returns 1710000000000L
+ every { conversation.unreadCount } returns 2
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ConversationsList(
+ conversations = listOf(conversation),
+ onConversationClick = {}
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun conversationItem_composesWithoutCrashing_whenOfflineAndNoMessages() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ val conversation = mockk(relaxed = true)
+ every { conversation.id } returns "conv-2"
+ every { conversation.userName } returns "Bob"
+ every { conversation.isOnline } returns false
+ every { conversation.userAddress } returns null
+ every { conversation.lastMessage } returns null
+ every { conversation.lastMessageTime } returns 0L
+ every { conversation.unreadCount } returns 0
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ConversationItem(
+ conversation = conversation,
+ onClick = {}
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun emptyConversationsView_composesWithoutCrashing() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ activity.runOnUiThread {
+ activity.setContent {
+ EmptyConversationsView()
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ }
+
+ @Test
+ fun errorView_composesWithoutCrashing() {
+ val activity = Robolectric.buildActivity(ComponentActivity::class.java)
+ .create()
+ .start()
+ .resume()
+ .visible()
+ .get()
+
+ var retried = false
+
+ activity.runOnUiThread {
+ activity.setContent {
+ ErrorView(
+ errorMessage = "Network error",
+ onRetry = { retried = true }
+ )
+ }
+ }
+
+ Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull(activity)
+ assertNull(null.takeIf { retried })
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModelTest.kt
new file mode 100644
index 000000000..b8073fed6
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModelTest.kt
@@ -0,0 +1,187 @@
+package com.greybox.projectmesh.messaging.ui.viewmodels
+
+import android.content.SharedPreferences
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.SavedStateHandle
+import com.greybox.projectmesh.DeviceStatusManager
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.db.MeshDatabase
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.repository.ConversationRepository
+import com.greybox.projectmesh.server.AppServer
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.unmockkAll
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.net.InetAddress
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ChatScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+
+ mockkObject(DeviceStatusManager)
+ every { DeviceStatusManager.deviceStatusMap } returns MutableStateFlow(emptyMap())
+ every { DeviceStatusManager.isDeviceOnline(any()) } returns false
+ every { DeviceStatusManager.verifyDeviceStatus(any()) } just runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ private fun createViewModel(
+ virtualAddress: InetAddress = InetAddress.getByName("0.0.0.0"),
+ initialMessages: List = emptyList(),
+ messageFlowMessages: List = initialMessages,
+ addTransferResult: AppServer.OutgoingTransferInfo = mockk(relaxed = true)
+ ): Triple {
+ val mockPrefs = mockk(relaxed = true)
+ every { mockPrefs.getString("UUID", null) } returns "local-user"
+
+ val mockDb = mockk(relaxed = true)
+ every { mockDb.messageDao().getAll() } returns emptyList()
+ every { mockDb.messageDao().getChatMessagesSync(any()) } returns initialMessages
+ every { mockDb.messageDao().getChatMessagesFlow(any()) } returns flowOf(messageFlowMessages)
+ every { mockDb.messageDao().getChatMessagesFlowMultipleNames(any()) } returns flowOf(messageFlowMessages)
+ coEvery { mockDb.messageDao().addMessage(any()) } returns Unit
+
+ val mockConversationRepo = mockk(relaxed = true)
+
+ val mockAppServer = mockk(relaxed = true)
+ every { mockAppServer.addOutgoingTransfer(any(), any()) } returns addTransferResult
+
+ val mockUserRepo = mockk(relaxed = true)
+ coEvery { mockUserRepo.getUserByIp(any()) } returns null
+ GlobalApp.GlobalUserRepo.userRepository = mockUserRepo
+
+ val di = DI {
+ bind(tag = "settings") with singleton { mockPrefs }
+ bind() with singleton { mockDb }
+ bind() with singleton { mockAppServer }
+ bind() with singleton { mockConversationRepo }
+ }
+
+ val savedStateHandle = SavedStateHandle(
+ mapOf("virtualAddress" to virtualAddress)
+ )
+
+ return Triple(ChatScreenViewModel(di, savedStateHandle), mockDb, mockAppServer)
+ }
+
+ @Test
+ fun chatScreenViewModel_initializesUiState_withUnknownNameAndAddress() = runTest {
+ val virtualAddress = InetAddress.getByName("0.0.0.0")
+ val (viewModel, _, _) = createViewModel(
+ virtualAddress = virtualAddress,
+ initialMessages = emptyList(),
+ messageFlowMessages = emptyList()
+ )
+
+ advanceUntilIdle()
+ val state = viewModel.uiState.first()
+
+ assertEquals("Unknown", state.deviceName)
+ assertEquals(virtualAddress, state.virtualAddress)
+ assertTrue(state.allChatMessages.isEmpty())
+ }
+
+
+ @Test
+ fun sendChatMessage_savesMessageLocally_andSetsOfflineWarning_whenDeviceOffline() = runTest {
+ val virtualAddress = InetAddress.getByName("0.0.0.0")
+ every { DeviceStatusManager.isDeviceOnline(virtualAddress.hostAddress) } returns false
+ every { DeviceStatusManager.verifyDeviceStatus(any()) } just runs
+
+ val (viewModel, mockDb, _) = createViewModel(
+ virtualAddress = virtualAddress,
+ initialMessages = emptyList(),
+ messageFlowMessages = emptyList()
+ )
+
+ viewModel.sendChatMessage(
+ virtualAddress = virtualAddress,
+ message = "Test message",
+ file = null
+ )
+
+ advanceUntilIdle()
+ val state = viewModel.uiState.first()
+
+ coVerify { mockDb.messageDao().addMessage(any()) }
+ assertTrue(state.offlineWarning != null)
+ assertTrue(state.offlineWarning!!.contains("offline", ignoreCase = true))
+ }
+
+ @Test
+ fun addOutgoingTransfer_delegatesToAppServer() = runTest {
+ val expected = mockk(relaxed = true)
+ val virtualAddress = InetAddress.getByName("0.0.0.0")
+ val fileUri = mockk(relaxed = true)
+
+ val (viewModel, _, _) = createViewModel(
+ virtualAddress = virtualAddress,
+ addTransferResult = expected,
+ initialMessages = emptyList(),
+ messageFlowMessages = emptyList()
+ )
+
+ val result = viewModel.addOutgoingTransfer(fileUri, virtualAddress)
+
+ assertSame(expected, result)
+ }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(
+ private val dispatcher: TestDispatcher = StandardTestDispatcher()
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModelTest.kt
new file mode 100644
index 000000000..9f01ab41d
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModelTest.kt
@@ -0,0 +1,162 @@
+package com.greybox.projectmesh.messaging.ui.viewmodels
+
+import android.content.SharedPreferences
+import android.util.Log
+import com.greybox.projectmesh.DeviceStatusManager
+import com.greybox.projectmesh.messaging.data.entities.Conversation
+import com.greybox.projectmesh.messaging.repository.ConversationRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ConversationsHomeScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = ConversationsMainDispatcherRule()
+
+ private lateinit var mockPrefs: SharedPreferences
+ private lateinit var mockRepository: ConversationRepository
+ private lateinit var di: DI
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+
+ mockkObject(DeviceStatusManager)
+ every { DeviceStatusManager.deviceStatusMap } returns MutableStateFlow(emptyMap())
+
+ mockPrefs = mockk(relaxed = true)
+ every { mockPrefs.getString("UUID", null) } returns "local-user"
+
+ mockRepository = mockk(relaxed = true)
+ coEvery { mockRepository.markAsRead(any()) } returns Unit
+ coEvery { mockRepository.updateUserStatus(any(), any(), any()) } returns Unit
+
+ di = DI {
+ bind(tag = "settings") with singleton { mockPrefs }
+ bind() with singleton { mockRepository }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun conversationsHomeScreenViewModel_loadsConversations_andFiltersOutSelf() = runTest {
+ val selfConversation = mockk(relaxed = true)
+ every { selfConversation.userUuid } returns "local-user"
+ every { selfConversation.id } returns "self-conv"
+ every { selfConversation.userName } returns "Me"
+
+ val otherConversation = mockk(relaxed = true)
+ every { otherConversation.userUuid } returns "remote-user"
+ every { otherConversation.id } returns "remote-conv"
+ every { otherConversation.userName } returns "Alice"
+
+ every { mockRepository.getAllConversations() } returns flowOf(
+ listOf(selfConversation, otherConversation)
+ )
+
+ val viewModel = ConversationsHomeScreenViewModel(di)
+
+ advanceUntilIdle()
+ val state = viewModel.uiState.first()
+
+ assertFalse(state.isLoading)
+ assertEquals(1, state.conversations.size)
+ assertEquals("remote-user", state.conversations.first().userUuid)
+ assertEquals("remote-conv", state.conversations.first().id)
+ assertEquals(null, state.error)
+ }
+
+ @Test
+ fun conversationsHomeScreenViewModel_setsError_whenRepositoryFlowFails() = runTest {
+ every { mockRepository.getAllConversations() } returns flow {
+ throw RuntimeException("boom")
+ }
+
+ val viewModel = ConversationsHomeScreenViewModel(di)
+
+ advanceUntilIdle()
+ val state = viewModel.uiState.first()
+
+ assertFalse(state.isLoading)
+ assertTrue(state.error != null)
+ assertTrue(state.error!!.contains("Failed to load conversations"))
+ assertTrue(state.error!!.contains("boom"))
+ }
+
+ @Test
+ fun refreshConversations_callsRepositoryAgain() = runTest {
+ every { mockRepository.getAllConversations() } returns flowOf(emptyList())
+
+ val viewModel = ConversationsHomeScreenViewModel(di)
+ advanceUntilIdle()
+
+ viewModel.refreshConversations()
+ advanceUntilIdle()
+
+ io.mockk.verify(exactly = 2) { mockRepository.getAllConversations() }
+ }
+
+ @Test
+ fun markConversationAsRead_delegatesToRepository() = runTest {
+ every { mockRepository.getAllConversations() } returns flowOf(emptyList())
+
+ val viewModel = ConversationsHomeScreenViewModel(di)
+ advanceUntilIdle()
+
+ viewModel.markConversationAsRead("conv-123")
+ advanceUntilIdle()
+
+ coVerify { mockRepository.markAsRead("conv-123") }
+ }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ConversationsMainDispatcherRule(
+ private val dispatcher: TestDispatcher = StandardTestDispatcher()
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt
new file mode 100644
index 000000000..d5370c5a1
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt
@@ -0,0 +1,56 @@
+package com.greybox.projectmesh.messaging.utils
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * JVM-only tests for ConversationUtils.createConversationId.
+ *
+ * Verifies:
+ * - stable ordering of UUID pairs
+ * - identical UUIDs produce expected ID
+ * - special-case handling for device UUIDs
+ * - offline device UUIDs follow same special rule
+ */
+class ConversationUtilsTest {
+
+ // ----------------------------------------
+ // Ordering: "a","b" should equal "b","a"
+ // ----------------------------------------
+ @Test
+ fun checkCreateConversationId() {
+ val id1 = ConversationUtils.createConversationId("a", "b")
+ val id2 = ConversationUtils.createConversationId("b", "a")
+
+ assertEquals("a-b", id1)
+ assertEquals(id1, id2)
+ }
+
+ // --------------------------------------
+ // Identical values should join naturally
+ // --------------------------------------
+ @Test
+ fun checkIdenticalUuids() {
+ val id = ConversationUtils.createConversationId("same", "same")
+ assertEquals("same-same", id)
+ }
+
+ // -----------------------------------------------------
+ // Special rule: remote UUID "test-device-uuid" maps to
+ // "local-user-"
+ // -----------------------------------------------------
+ @Test
+ fun checkSpecialCase() {
+ val id = ConversationUtils.createConversationId("anything", "test-device-uuid")
+ assertEquals("local-user-test-device-uuid", id)
+ }
+
+ // -----------------------------------------------------
+ // Offline device rule: mirrors test-device behavior
+ // -----------------------------------------------------
+ @Test
+ fun checkSecondSpecialCase() {
+ val id = ConversationUtils.createConversationId("anything", "offline-test-device-uuid")
+ assertEquals("local-user-offline-test-device-uuid", id)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt
new file mode 100644
index 000000000..cef18e398
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt
@@ -0,0 +1,31 @@
+package com.greybox.projectmesh.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LoggerTest {
+
+ @Test
+ fun buildTag_prefixesWithMeshChat() {
+ val result = Logger.buildTag("ChatScreen")
+ assertEquals("MeshChat_ChatScreen", result)
+ }
+
+ @Test
+ fun buildTag_handlesEmptyTag() {
+ val result = Logger.buildTag("")
+ assertEquals("MeshChat_", result)
+ }
+
+ @Test
+ fun buildCriticalTag_appendsCriticalSuffix() {
+ val result = Logger.buildCriticalTag("Network")
+ assertEquals("MeshChat_Network_CRITICAL", result)
+ }
+
+ @Test
+ fun buildCriticalTag_handlesEmptyTag() {
+ val result = Logger.buildCriticalTag("")
+ assertEquals("MeshChat__CRITICAL", result)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt
new file mode 100644
index 000000000..9dde1d24b
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt
@@ -0,0 +1,52 @@
+package com.greybox.projectmesh.messaging.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.kodein.di.DI
+
+class MessageMigrationUtilsTest {
+
+ private val di = DI {}
+ private val utils = MessageMigrationUtils(di)
+
+ @Test
+ fun createConversationId_sortsNormalUuids() {
+ val uuid1 = "b-uuid"
+ val uuid2 = "a-uuid"
+
+ val result = utils.createConversationId(uuid1, uuid2)
+
+ assertEquals("a-uuid-b-uuid", result)
+ }
+
+ @Test
+ fun createConversationId_handlesTestDeviceUuid() {
+ val uuid1 = "some-other-uuid"
+ val uuid2 = "test-device-uuid"
+
+ val result = utils.createConversationId(uuid1, uuid2)
+
+ assertEquals("local-user-test-device-uuid", result)
+ }
+
+ @Test
+ fun createConversationId_handlesOfflineTestDeviceUuid() {
+ val uuid1 = "some-other-uuid"
+ val uuid2 = "offline-test-device-uuid"
+
+ val result = utils.createConversationId(uuid1, uuid2)
+
+ assertEquals("local-user-offline-test-device-uuid", result)
+ }
+
+ @Test
+ fun createConversationId_isDeterministicForSamePair() {
+ val uuidA = "1111-aaaa"
+ val uuidB = "2222-bbbb"
+
+ val id1 = utils.createConversationId(uuidA, uuidB)
+ val id2 = utils.createConversationId(uuidB, uuidA)
+
+ assertEquals(id1, id2)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt
new file mode 100644
index 000000000..f69ad567c
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt
@@ -0,0 +1,41 @@
+package com.greybox.projectmesh.messaging.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.util.TimeZone
+
+class MessageUtilsTest {
+
+ @Test
+ fun formatTimestamp_formatsToHoursAndMinutesInUtc() {
+ val originalTimeZone = TimeZone.getDefault()
+ try {
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
+ val timestamp = 0L
+ val result = MessageUtils.formatTimestamp(timestamp)
+ assertEquals("00:00", result)
+ } finally {
+ TimeZone.setDefault(originalTimeZone)
+ }
+ }
+
+ @Test
+ fun generateChatId_isOrderIndependent() {
+ val id1 = MessageUtils.generateChatId("alice", "bob")
+ val id2 = MessageUtils.generateChatId("bob", "alice")
+ assertEquals("alice-bob", id1)
+ assertEquals(id1, id2)
+ }
+
+ @Test
+ fun generateChatId_handlesSameUser() {
+ val id = MessageUtils.generateChatId("alice", "alice")
+ assertEquals("alice-alice", id)
+ }
+
+ @Test
+ fun generateChatId_isCaseSensitive() {
+ val id = MessageUtils.generateChatId("Alice", "alice")
+ assertEquals("Alice-alice", id)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt b/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt
new file mode 100644
index 000000000..6fad19e40
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt
@@ -0,0 +1,90 @@
+package com.greybox.projectmesh.navigation
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * JVM unit tests for the BottomNavItem sealed class.
+ *
+ * We only check pure data:
+ * - each object has the expected route and title
+ * - all routes are unique
+ * - icons are present (non-null references)
+ *
+ * No Compose runtime or Android APIs are used here, so this is safe
+ * as a local unit test.
+ */
+class BottomNavItemTest {
+
+ private val allItems = listOf(
+ BottomNavItem.Home,
+ BottomNavItem.Network,
+ BottomNavItem.Send,
+ BottomNavItem.Receive,
+ BottomNavItem.Log,
+ BottomNavItem.Settings,
+ BottomNavItem.Chat
+ )
+
+ @Test
+ fun allItems_haveExpectedRoutesAndTitles() {
+ // route, title pairs we expect
+ val expected = mapOf(
+ BottomNavItem.Home to ("home" to "Home"),
+ BottomNavItem.Network to ("network" to "Network"),
+ BottomNavItem.Send to ("send" to "Send"),
+ BottomNavItem.Receive to ("receive" to "Receive"),
+ BottomNavItem.Log to ("log" to "Log"),
+ BottomNavItem.Settings to ("settings" to "Settings"),
+ BottomNavItem.Chat to ("chat" to "Chat"),
+ )
+
+ for (item in allItems) {
+ val (expRoute, expTitle) = expected[item]
+ ?: error("Missing expectations for $item")
+
+ assertEquals("Wrong route for ${item::class.simpleName}", expRoute, item.route)
+ assertEquals("Wrong title for ${item::class.simpleName}", expTitle, item.title)
+ }
+ }
+
+ @Test
+ fun allItems_haveNonNullIcons() {
+ allItems.forEach { item ->
+ assertNotNull(
+ "Icon must not be null for ${item::class.simpleName}",
+ item.icon
+ )
+ }
+ }
+
+ @Test
+ fun routes_areUniqueAcrossAllItems() {
+ val routes = allItems.map { it.route }
+ val distinctRoutes = routes.toSet()
+
+ assertEquals(
+ "Each BottomNavItem should use a unique route",
+ distinctRoutes.size,
+ routes.size
+ )
+ }
+
+ @Test
+ fun sealedHierarchy_containsExactlyExpectedItems() {
+ // This guards against someone adding a new object without updating tests.
+ val classes = allItems.map { it::class.simpleName }.toSet()
+
+ val expectedNames = setOf(
+ "Home",
+ "Network",
+ "Send",
+ "Receive",
+ "Log",
+ "Settings",
+ "Chat"
+ )
+
+ assertEquals(expectedNames, classes)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt b/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt
new file mode 100644
index 000000000..d82e1ced5
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt
@@ -0,0 +1,45 @@
+package com.greybox.projectmesh.navigation
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * JVM unit tests for the NavigationItem data class.
+ *
+ * NOTE:
+ * - Composables & NavController CANNOT be JVM tested.
+ * - We only verify that NavigationItem behaves as a proper
+ * Kotlin data class (copy, equals, hashCode).
+ */
+class NavigationItemTest {
+
+ // Just need some non-null ImageVector instance
+ private val dummyIcon: ImageVector = ImageVector.Builder(
+ defaultWidth = 24.dp, // <-- Dp, not Float
+ defaultHeight = 24.dp, // <-- Dp, not Float
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ ).build()
+
+ @Test
+ fun navigationItem_copyEqualsHashCodeCorrect() {
+ val original = NavigationItem(
+ route = "home",
+ label = "Home",
+ icon = dummyIcon
+ )
+
+ val copy = original.copy()
+ val modified = original.copy(route = "different")
+
+ // Same data → equal
+ assertEquals(original, copy)
+ assertEquals(original.hashCode(), copy.hashCode())
+
+ // Different route → not equal
+ assertNotEquals(original, modified)
+ assertEquals("different", modified.route)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/server/AppServerTest.kt b/app/src/test/java/com/greybox/projectmesh/server/AppServerTest.kt
new file mode 100644
index 000000000..cf1510fc6
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/server/AppServerTest.kt
@@ -0,0 +1,337 @@
+package com.greybox.projectmesh.server
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.net.Uri
+import android.util.Log
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.db.MeshDatabase
+import com.greybox.projectmesh.messaging.data.dao.MessageDao
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.messaging.network.MessageNetworkHandler
+import com.greybox.projectmesh.testing.TestDeviceService
+import com.greybox.projectmesh.user.UserEntity
+import com.greybox.projectmesh.user.UserRepository
+import com.ustadmobile.meshrabiya.log.MNetLogger
+import fi.iki.elonen.NanoHTTPD
+import io.mockk.Call
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkObject
+import io.mockk.unmockkStatic
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.Buffer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.IOException
+import java.net.InetAddress
+import java.net.URI
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class AppServerTest {
+
+ private lateinit var context: Context
+ private lateinit var prefs: SharedPreferences
+ private lateinit var httpClient: OkHttpClient
+ private lateinit var call: okhttp3.Call
+ private lateinit var logger: MNetLogger
+ private lateinit var db: MeshDatabase
+ private lateinit var messageDao: MessageDao
+ private lateinit var userRepository: UserRepository
+ private lateinit var receiveDir: File
+ private lateinit var server: AppServer
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+ every { Log.i(any(), any()) } returns 0
+
+ context = ApplicationProvider.getApplicationContext()
+ prefs = context.getSharedPreferences("app_server_test", Context.MODE_PRIVATE)
+ prefs.edit().clear().putString("UUID", "local-uuid").commit()
+
+ httpClient = mockk(relaxed = true)
+ call = mockk(relaxed = true)
+ logger = mockk(relaxed = true)
+ db = mockk(relaxed = true)
+ messageDao = mockk(relaxed = true)
+ userRepository = mockk(relaxed = true)
+ receiveDir = File(context.cacheDir, "appserver-test-${System.nanoTime()}").apply {
+ mkdirs()
+ }
+
+ every { db.messageDao() } returns messageDao
+
+ val di = DI {
+ bind(tag = "settings") with singleton { prefs }
+ }
+
+ server = AppServer(
+ appContext = context,
+ httpClient = httpClient,
+ mLogger = logger,
+ name = "test-node",
+ port = 0,
+ localVirtualAddr = InetAddress.getByName("10.0.0.1"),
+ receiveDir = receiveDir,
+ json = Json { encodeDefaults = true },
+ db = db,
+ di = di,
+ userRepository = userRepository
+ )
+ }
+
+ @After
+ fun tearDown() {
+ runCatching { unmockkObject(MessageNetworkHandler.Companion) }
+ unmockkStatic(Log::class)
+ clearAllMocks()
+ server.close()
+ receiveDir.deleteRecursively()
+ prefs.edit().clear().commit()
+ }
+
+ @Test
+ fun serve_ping_returnsPong() {
+ val response = server.serve(session(uri = "/ping"))
+
+ assertEquals(NanoHTTPD.Response.Status.OK, response.status)
+ assertEquals("PONG", readBody(response))
+ }
+
+ @Test
+ fun serve_myInfo_returnsLocalUserJsonWithVirtualAddress() {
+ coEvery { userRepository.getUser("local-uuid") } returns UserEntity(
+ uuid = "local-uuid",
+ name = "Local User",
+ address = null
+ )
+
+ val response = server.serve(session(uri = "/myinfo"))
+ val body = readBody(response)
+
+ assertEquals(NanoHTTPD.Response.Status.OK, response.status)
+ assertTrue(body.contains("\"uuid\":\"local-uuid\""))
+ assertTrue(body.contains("\"name\":\"Local User\""))
+ assertTrue(body.contains("\"address\":\"10.0.0.1\""))
+ }
+
+ @Test
+ fun serve_updateUserInfo_withNormalUserPayload_returnsBadRequest() {
+ val payload = """{"uuid":"remote-1","name":"Alice","address":"10.0.0.5"}"""
+
+ val response = server.serve(
+ session(
+ uri = "/updateUserInfo",
+ method = NanoHTTPD.Method.POST,
+ body = payload
+ )
+ )
+
+ assertEquals(NanoHTTPD.Response.Status.BAD_REQUEST, response.status)
+ assertTrue(readBody(response).contains("Invalid JSON schema"))
+ coVerify(exactly = 0) { userRepository.insertOrUpdateUser(any(), any(), any()) }
+ }
+
+ @Test
+ fun serve_chat_withEmptyPayload_returnsBadRequest() {
+ val response = server.serve(
+ session(
+ uri = "/chat",
+ method = NanoHTTPD.Method.POST,
+ postData = ""
+ )
+ )
+
+ assertEquals(NanoHTTPD.Response.Status.BAD_REQUEST, response.status)
+ assertEquals("Empty or missing JSON payload", readBody(response))
+ }
+
+ @Test
+ fun serve_chat_withValidPayload_savesHandledMessage() {
+ mockkObject(MessageNetworkHandler.Companion)
+ val handledMessage = Message(
+ id = 0,
+ dateReceived = 1_000L,
+ content = "handled",
+ sender = "Alice",
+ chat = "chat-1"
+ )
+ every {
+ MessageNetworkHandler.handleIncomingMessage(any(), any(), any(), any())
+ } returns handledMessage
+ coEvery { messageDao.addMessage(any()) } returns Unit
+
+ val payload = """
+ {"id":0,"chat":"chat-1","content":"hello","dateReceived":1000,"sender":"10.0.0.50"}
+ """.trimIndent()
+
+ val response = server.serve(
+ session(
+ uri = "/chat",
+ method = NanoHTTPD.Method.POST,
+ postData = payload
+ )
+ )
+
+ assertEquals(NanoHTTPD.Response.Status.OK, response.status)
+ assertEquals("OK", readBody(response))
+ coVerify(timeout = 2_000, exactly = 1) { messageDao.addMessage(handledMessage) }
+ }
+
+ @Test
+ fun addOutgoingTransfer_withFileUri_tracksTransferAndMakesSendRequest() {
+ val file = File.createTempFile("app-server", ".txt", context.cacheDir).apply {
+ writeText("mesh payload")
+ deleteOnExit()
+ }
+ val requestSlot = slot()
+ every { httpClient.newCall(capture(requestSlot)) } returns call
+ every { call.execute() } answers {
+ Response.Builder()
+ .request(requestSlot.captured)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body("OK".toResponseBody("text/plain".toMediaType()))
+ .build()
+ }
+
+ val transfer = server.addOutgoingTransfer(
+ uri = Uri.fromFile(file),
+ toNode = InetAddress.getByName("10.0.0.55")
+ )
+
+ assertEquals(file.name, transfer.name)
+ assertEquals(file.length().toInt(), transfer.size)
+ assertEquals(AppServer.Status.PENDING, transfer.status)
+
+ val tracked = runBlocking { server.outgoingTransfers.first() }
+ assertEquals(1, tracked.size)
+ assertEquals(transfer, tracked.single())
+
+ io.mockk.verify(timeout = 2_000, exactly = 1) { httpClient.newCall(any()) }
+ assertTrue(requestSlot.captured.url.toString().contains("/send?id=${transfer.id}"))
+ assertEquals("POST", requestSlot.captured.method)
+ }
+
+ @Test
+ fun sendChatMessageWithStatus_forTestDevice_savesEchoAndReturnsTrue() = runBlocking {
+ coEvery { messageDao.addMessage(any()) } returns Unit
+
+ val fileUri = URI.create("file:///tmp/sample.txt")
+ val delivered = server.sendChatMessageWithStatus(
+ address = InetAddress.getByName(TestDeviceService.TEST_DEVICE_IP),
+ time = 123L,
+ message = "ping",
+ f = fileUri
+ )
+
+ assertTrue(delivered)
+ coVerify(exactly = 1) {
+ messageDao.addMessage(
+ match {
+ it.content == "Echo: ping" &&
+ it.sender == TestDeviceService.TEST_DEVICE_NAME &&
+ it.file == fileUri
+ }
+ )
+ }
+ }
+
+ @Test
+ fun sendChatMessageWithStatus_forRealDevice_postsJsonAndReturnsTrue() = runBlocking {
+ val requestSlot = slot()
+ every { httpClient.newCall(capture(requestSlot)) } returns call
+ every { call.execute() } answers {
+ Response.Builder()
+ .request(requestSlot.captured)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body("OK".toResponseBody("text/plain".toMediaType()))
+ .build()
+ }
+
+ val delivered = server.sendChatMessageWithStatus(
+ address = InetAddress.getByName("10.0.0.8"),
+ time = 456L,
+ message = "hello world",
+ f = null
+ )
+
+ assertTrue(delivered)
+ assertEquals("POST", requestSlot.captured.method)
+ assertEquals("hello world", requestSlot.captured.url.queryParameter("chatMessage"))
+ assertEquals("456", requestSlot.captured.url.queryParameter("time"))
+ assertEquals("10.0.0.1", requestSlot.captured.url.queryParameter("senderIp"))
+
+ val body = Buffer().also { requestSlot.captured.body!!.writeTo(it) }.readUtf8()
+ assertTrue(body.contains("\"content\":\"hello world\""))
+ assertTrue(body.contains("\"chat\":\"10.0.0.8\""))
+ }
+
+ @Test
+ fun checkDeviceReachable_whenCallThrows_returnsFalse() {
+ every { httpClient.newCall(any()) } returns call
+ every { call.execute() } throws IOException("network down")
+
+ val reachable = server.checkDeviceReachable(InetAddress.getByName("10.0.0.9"))
+
+ assertFalse(reachable)
+ }
+
+ private fun session(
+ uri: String,
+ method: NanoHTTPD.Method = NanoHTTPD.Method.GET,
+ body: String = "",
+ query: String = "",
+ postData: String? = null
+ ): NanoHTTPD.IHTTPSession {
+ val session = mockk(relaxed = true)
+ every { session.uri } returns uri
+ every { session.method } returns method
+ every { session.queryParameterString } returns query
+ every { session.inputStream } returns ByteArrayInputStream(body.toByteArray())
+ if (postData != null) {
+ every { session.parseBody(any()) } answers {
+ firstArg>()["postData"] = postData
+ }
+ }
+ return session
+ }
+
+ private fun readBody(response: NanoHTTPD.Response): String {
+ return response.data.bufferedReader().use { it.readText() }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt b/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt
new file mode 100644
index 000000000..3e15809e2
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt
@@ -0,0 +1,109 @@
+package com.greybox.projectmesh.server
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.io.ByteArrayInputStream
+
+/**
+ * JVM-only tests for InputStreamCounter.
+ *
+ * Verifies:
+ * - single-byte reads
+ * - buffered reads
+ * - offset reads
+ * - EOF behavior
+ * - close() flag
+ */
+class InputStreamCounterTest {
+
+ // -----------------------------------------
+ // Single-byte read: should count each byte
+ // -----------------------------------------
+ @Test
+ fun countSingleBytes() {
+ val data = "hello world".toByteArray()
+ val input = ByteArrayInputStream(data)
+ val counter = InputStreamCounter(input)
+
+ while (true) {
+ val result = counter.read()
+ if (result == -1) break
+ }
+
+ assertEquals(data.size, counter.bytesRead)
+ assertFalse(counter.closed)
+ }
+
+ // ----------------------------------------------------------
+ // Buffered read: reading in chunks should count total bytes
+ // ----------------------------------------------------------
+ @Test
+ fun readBufferBytes() {
+ val data = ByteArray(4096) { it.toByte() }
+ val input = ByteArrayInputStream(data)
+ val counter = InputStreamCounter(input)
+
+ val buffer = ByteArray(1024)
+ while (true) {
+ val n = counter.read(buffer)
+ if (n == -1) break
+ }
+
+ assertEquals(data.size, counter.bytesRead)
+ }
+
+ // ----------------------------------------------------------------
+ // Offset read: read(buffer, off, len) must still count accurately
+ // ----------------------------------------------------------------
+ @Test
+ fun countOffsetBytes() {
+ val data = "abcdefghi".toByteArray()
+ val input = ByteArrayInputStream(data)
+ val counter = InputStreamCounter(input)
+
+ val buffer = ByteArray(10)
+ while (true) {
+ val n = counter.read(buffer, 1, 4)
+ if (n == -1) break
+ }
+
+ assertEquals(data.size, counter.bytesRead)
+ }
+
+ // ---------------------------------------------------------
+ // EOF behavior: read after EOF should return -1 and not add
+ // ---------------------------------------------------------
+ @Test
+ fun checkEOF() {
+ val data = "test".toByteArray()
+ val input = ByteArrayInputStream(data)
+ val counter = InputStreamCounter(input)
+
+ val buffer = ByteArray(2)
+ while (counter.read(buffer) != -1) {
+ // consume all data
+ }
+
+ val before = counter.bytesRead
+ val eofRead = counter.read(buffer)
+
+ assertEquals(-1, eofRead)
+ assertEquals(before, counter.bytesRead)
+ }
+
+ // --------------------------
+ // close(): should set flag
+ // --------------------------
+ @Test
+ fun checkClose() {
+ val data = "xyz".toByteArray()
+ val input = ByteArrayInputStream(data)
+ val counter = InputStreamCounter(input)
+
+ assertFalse(counter.closed)
+
+ counter.close()
+
+ assertTrue(counter.closed)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceEntryTest.kt b/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceEntryTest.kt
new file mode 100644
index 000000000..ea65b5ba0
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceEntryTest.kt
@@ -0,0 +1,62 @@
+package com.greybox.projectmesh.testing
+
+import android.util.Log
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class TestDeviceEntryTest {
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+ every { Log.e(any(), any(), any()) } returns 0
+ every { Log.println(any(), any(), any()) } returns 0
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Log::class)
+ clearAllMocks()
+ }
+
+ @Test
+ fun createTestEntry_buildsExpectedAddressAndOriginatorMetadata() {
+ val (addressInt, originator) = TestDeviceEntry.createTestEntry()
+
+ try {
+ assertEquals(TestDeviceService.TEST_DEVICE_IP, originator.lastHopRealInetAddr.hostAddress)
+ assertEquals(addressInt, originator.lastHopAddr)
+ assertEquals(50.toShort(), originator.originatorMessage.pingTimeSum)
+ assertEquals(1.toByte(), originator.hopCount)
+ assertEquals(4242, originator.lastHopRealPort)
+ assertTrue(originator.timeReceived > 0L)
+ } finally {
+ originator.receivedFromSocket.close(true)
+ }
+ }
+
+ @Test
+ fun createTestEntry_returnsSocketBoundToLocalPort() {
+ val (_, originator) = TestDeviceEntry.createTestEntry()
+
+ try {
+ assertTrue(originator.receivedFromSocket.localPort > 0)
+ assertEquals(TestDeviceService.TEST_DEVICE_IP, originator.lastHopRealInetAddr.hostAddress)
+ } finally {
+ originator.receivedFromSocket.close(true)
+ }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceServiceTest.kt b/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceServiceTest.kt
new file mode 100644
index 000000000..af6654aa2
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/testing/TestDeviceServiceTest.kt
@@ -0,0 +1,157 @@
+package com.greybox.projectmesh.testing
+
+import android.util.Log
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.messaging.data.entities.Message
+import com.greybox.projectmesh.user.UserEntity
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.InetAddress
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class TestDeviceServiceTest {
+
+ private lateinit var userRepository: UserRepository
+
+ @Before
+ fun setUp() {
+ mockkStatic(Log::class)
+ everyLog()
+ userRepository = mockk(relaxed = true)
+ GlobalApp.GlobalUserRepo.userRepository = userRepository
+ setFlag("isInitialized", false)
+ setFlag("offlineDeviceInitialized", false)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Log::class)
+ clearAllMocks()
+ setFlag("isInitialized", false)
+ setFlag("offlineDeviceInitialized", false)
+ }
+
+ @Test
+ fun initialize_whenUsersDoNotExist_insertsOnlineAndOfflineTestUsers() {
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP) } returns null
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP_OFFLINE) } returns null
+
+ TestDeviceService.initialize()
+
+ coVerify(exactly = 1) {
+ userRepository.insertOrUpdateUser(
+ "temp-${TestDeviceService.TEST_DEVICE_IP}",
+ TestDeviceService.TEST_DEVICE_NAME,
+ TestDeviceService.TEST_DEVICE_IP
+ )
+ }
+ coVerify(exactly = 1) {
+ userRepository.insertOrUpdateUser(
+ "temp-offline-${TestDeviceService.TEST_DEVICE_IP_OFFLINE}",
+ TestDeviceService.TEST_DEVICE_NAME_OFFLINE,
+ null
+ )
+ }
+ }
+
+ @Test
+ fun initialize_whenUsersExist_updatesExistingRecords() {
+ val online = UserEntity("existing-online", "Old Online", TestDeviceService.TEST_DEVICE_IP)
+ val offline = UserEntity("existing-offline", "Old Offline", "10.0.0.2")
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP) } returns online
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP_OFFLINE) } returns offline
+
+ TestDeviceService.initialize()
+
+ coVerify(exactly = 1) {
+ userRepository.insertOrUpdateUser(
+ "existing-online",
+ TestDeviceService.TEST_DEVICE_NAME,
+ TestDeviceService.TEST_DEVICE_IP
+ )
+ }
+ coVerify(exactly = 1) {
+ userRepository.insertOrUpdateUser(
+ "existing-offline",
+ TestDeviceService.TEST_DEVICE_NAME_OFFLINE,
+ null
+ )
+ }
+ }
+
+ @Test
+ fun initialize_calledTwice_isIdempotent() {
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP) } returns null
+ coEvery { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP_OFFLINE) } returns null
+
+ TestDeviceService.initialize()
+ TestDeviceService.initialize()
+
+ coVerify(exactly = 1) { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP) }
+ coVerify(exactly = 1) { userRepository.getUserByIp(TestDeviceService.TEST_DEVICE_IP_OFFLINE) }
+ }
+
+ @Test
+ fun helperMethods_identifyAddressesAndReturnExpectedIp() {
+ val online = InetAddress.getByName(TestDeviceService.TEST_DEVICE_IP)
+ val offline = InetAddress.getByName(TestDeviceService.TEST_DEVICE_IP_OFFLINE)
+
+ assertTrue(TestDeviceService.isOnlineTestDevice(online))
+ assertTrue(TestDeviceService.isOfflineTestDevice(offline))
+ assertTrue(TestDeviceService.isTestDevice(online))
+ assertFalse(TestDeviceService.isTestDevice(offline))
+ assertEquals(TestDeviceService.TEST_DEVICE_IP, TestDeviceService.getTestDeviceAddress().hostAddress)
+ }
+
+ @Test
+ fun createEchoResponse_prefixesContentAndPreservesChat() {
+ val original = Message(
+ id = 5,
+ dateReceived = 100L,
+ content = "hello",
+ sender = "Me",
+ chat = "chat-1"
+ )
+
+ val echo = TestDeviceService.createEchoResponse(original)
+
+ assertEquals("Echo: hello", echo.content)
+ assertEquals(TestDeviceService.TEST_DEVICE_NAME, echo.sender)
+ assertEquals("chat-1", echo.chat)
+ assertEquals(0, echo.id)
+ }
+
+ private fun setFlag(fieldName: String, value: Boolean) {
+ val outerField = TestDeviceService::class.java.declaredFields.firstOrNull { it.name == fieldName }
+ if (outerField != null) {
+ outerField.isAccessible = true
+ outerField.setBoolean(null, value)
+ return
+ }
+
+ val companionField = TestDeviceService.Companion::class.java.getDeclaredField(fieldName)
+ companionField.isAccessible = true
+ companionField.setBoolean(TestDeviceService.Companion, value)
+ }
+
+ private fun everyLog() {
+ io.mockk.every { Log.d(any(), any()) } returns 0
+ io.mockk.every { Log.e(any(), any()) } returns 0
+ io.mockk.every { Log.e(any(), any(), any()) } returns 0
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/testutil/MainDispatcherFile.kt b/app/src/test/java/com/greybox/projectmesh/testutil/MainDispatcherFile.kt
new file mode 100644
index 000000000..3b239eff2
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/testutil/MainDispatcherFile.kt
@@ -0,0 +1,24 @@
+package com.greybox.projectmesh.testutil
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(
+ private val dispatcher: TestDispatcher = StandardTestDispatcher()
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt b/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt
new file mode 100644
index 000000000..e1cd7c5ff
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt
@@ -0,0 +1,77 @@
+package com.greybox.projectmesh.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * JVM unit tests for the theme layer:
+ *
+ * - Color constants in Color.kt
+ * - AppTheme enum in Theme.kt
+ * - Typography definition in Type.kt
+ *
+ * NOTE:
+ * - We do NOT run the ProjectMeshTheme composable here,
+ * because that requires a real Compose runtime / Android.
+ * - Instrumented tests will check the actual MaterialTheme
+ * behavior (dark/light, system theme, etc.).
+ */
+class ThemeLayerTest {
+
+ // -----------------------------
+ // Color constants (Color.kt)
+ // -----------------------------
+
+ @Test
+ fun colors_haveExpectedArgbValues() {
+ // Light variants
+ assertEquals(Color(0xFFD0BCFF), Purple80)
+ assertEquals(Color(0xFFCCC2DC), PurpleGrey80)
+ assertEquals(Color(0xFFEFB8C8), Pink80)
+
+ // Darker variants
+ assertEquals(Color(0xFF6650A4), Purple40)
+ assertEquals(Color(0xFF625B71), PurpleGrey40)
+ assertEquals(Color(0xFF7D5260), Pink40)
+ }
+
+ // -----------------------------
+ // AppTheme enum (Theme.kt)
+ // -----------------------------
+
+ @Test
+ fun appTheme_containsExpectedValuesInOrder() {
+ val values = enumValues().toList()
+
+ assertEquals(3, values.size)
+ assertEquals(AppTheme.SYSTEM, values[0])
+ assertEquals(AppTheme.LIGHT, values[1])
+ assertEquals(AppTheme.DARK, values[2])
+
+ val names = values.map { it.name }.toSet()
+ assertEquals(setOf("SYSTEM", "LIGHT", "DARK"), names)
+ }
+
+ // -----------------------------
+ // Typography (Type.kt)
+ // -----------------------------
+
+ @Test
+ fun typography_bodyLarge_hasExpectedDefaults() {
+ // Typography is the object defined in Type.kt
+ val body = Typography.bodyLarge
+
+ // Font family & weight
+ assertEquals(FontFamily.Default, body.fontFamily)
+ assertEquals(FontWeight.Normal, body.fontWeight)
+
+ // Sizes (we compare the .value floats for simplicity)
+ assertEquals(16f, body.fontSize.value, 0.0f)
+ assertEquals(24f, body.lineHeight.value, 0.0f)
+ assertEquals(0.5f, body.letterSpacing.value, 0.0f)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/user/UserDaoTest.kt b/app/src/test/java/com/greybox/projectmesh/user/UserDaoTest.kt
new file mode 100644
index 000000000..685bd7f28
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/user/UserDaoTest.kt
@@ -0,0 +1,109 @@
+package com.greybox.projectmesh.user
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.db.MeshDatabase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class UserDaoTest {
+
+ private lateinit var db: MeshDatabase
+ private lateinit var dao: UserDao
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ MeshDatabase::class.java
+ ).allowMainThreadQueries().build()
+
+ dao = db.userDao()
+ }
+
+ @After
+ fun tearDown() {
+ db.close()
+ }
+
+ @Test
+ fun insert_and_getByUuid_work() = runTest {
+ val user = UserEntity("u1", "Alice", "10.0.0.1", 10L)
+
+ dao.insertUser(user)
+
+ val got = dao.getUserByUuid("u1")
+ assertEquals(user, got)
+ }
+
+ @Test
+ fun getUserByIp_returnsMatchingUser() = runTest {
+ dao.insertUser(UserEntity("u1", "A", "10.0.0.1", null))
+ dao.insertUser(UserEntity("u2", "B", "10.0.0.2", null))
+
+ val got = dao.getUserByIp("10.0.0.2")
+ assertEquals("u2", got?.uuid)
+ assertEquals("B", got?.name)
+ }
+
+ @Test
+ fun updateUser_replacesExistingRowValues() = runTest {
+ dao.insertUser(UserEntity("u3", "Old", "10.0.0.3", 100L))
+
+ dao.updateUser(UserEntity("u3", "New", null, 200L))
+
+ val got = dao.getUserByUuid("u3")
+ assertEquals("New", got?.name)
+ assertNull(got?.address)
+ assertEquals(200L, got?.lastSeen)
+ }
+
+ @Test
+ fun hasWithID_returnsTrueWhenExists_andFalseWhenMissing() = runTest {
+ dao.insertUser(UserEntity("u4", "Dana", "10.0.0.4", null))
+
+ assertTrue(dao.hasWithID("u4"))
+ assertFalse(dao.hasWithID("missing"))
+ }
+
+ @Test
+ fun getAllConnectedUsers_filtersOutNullAddresses() = runTest {
+ dao.insertUser(UserEntity("u5", "Connected", "10.0.0.5", null))
+ dao.insertUser(UserEntity("u6", "Offline", null, null))
+
+ val connected = dao.getAllConnectedUsers()
+
+ assertEquals(1, connected.size)
+ assertEquals("u5", connected.single().uuid)
+ }
+
+ @Test
+ fun getAllUsers_returnsAllRows() = runTest {
+ dao.insertUser(UserEntity("u7", "A", null, null))
+ dao.insertUser(UserEntity("u8", "B", "10.0.0.8", null))
+
+ val all = dao.getAllUsers()
+
+ assertEquals(2, all.size)
+ assertEquals(setOf("u7", "u8"), all.map { it.uuid }.toSet())
+ }
+
+ @Test
+ fun missingQueries_returnNull() = runTest {
+ assertNull(dao.getUserByUuid("none"))
+ assertNull(dao.getUserByIp("0.0.0.0"))
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/user/UserEntityTest.kt b/app/src/test/java/com/greybox/projectmesh/user/UserEntityTest.kt
new file mode 100644
index 000000000..a1d5f1818
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/user/UserEntityTest.kt
@@ -0,0 +1,62 @@
+package com.greybox.projectmesh.user
+
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class UserEntityTest {
+
+ @Test
+ fun constructor_defaults_addressAndLastSeen_toNull() {
+ val entity = UserEntity(
+ uuid = "u1",
+ name = "Alice"
+ )
+
+ assertNull(entity.address)
+ assertNull(entity.lastSeen)
+ }
+
+ @Test
+ fun dataClass_equality_hashCode_andCopy_areConsistent() {
+ val a = UserEntity("u2", "Bob", "10.0.0.2", 100L)
+ val b = UserEntity("u2", "Bob", "10.0.0.2", 100L)
+ val c = a.copy(name = "Bobby")
+
+ assertEquals(a, b)
+ assertEquals(a.hashCode(), b.hashCode())
+ assertNotEquals(a, c)
+ assertEquals("Bobby", c.name)
+ assertEquals("u2", c.uuid)
+ }
+
+ @Test
+ fun nullableFields_supportOfflineAndUnknownLastSeenCases() {
+ val offline = UserEntity(
+ uuid = "u3",
+ name = "Offline Device",
+ address = null,
+ lastSeen = null
+ )
+
+ assertNull(offline.address)
+ assertNull(offline.lastSeen)
+ }
+
+ @Test
+ fun serialization_roundTrip_preservesAllFields() {
+ val original = UserEntity(
+ uuid = "u4",
+ name = "Carol",
+ address = "192.168.1.10",
+ lastSeen = 999L
+ )
+
+ val encoded = Json.encodeToString(UserEntity.serializer(), original)
+ val decoded = Json.decodeFromString(UserEntity.serializer(), encoded)
+
+ assertEquals(original, decoded)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/user/UserRepositoryTest.kt b/app/src/test/java/com/greybox/projectmesh/user/UserRepositoryTest.kt
new file mode 100644
index 000000000..f817583f3
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/user/UserRepositoryTest.kt
@@ -0,0 +1,178 @@
+package com.greybox.projectmesh.user
+
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UserRepositoryTest {
+
+ private lateinit var userDao: UserDao
+ private lateinit var repo: UserRepository
+
+ @Before
+ fun setUp() {
+ userDao = mockk(relaxed = true)
+ repo = UserRepository(userDao)
+ }
+
+ @After
+ fun tearDown() {
+ clearAllMocks()
+ }
+
+ @Test
+ fun insertOrUpdateUser_whenNoExistingUser_insertsNewUser() = runTest {
+ val uuid = "u1"
+ val name = "Alice"
+ val address = "10.0.0.1"
+
+ coEvery { userDao.getUserByUuid(uuid) } returns null
+ coEvery { userDao.insertUser(any()) } returns Unit
+
+ repo.insertOrUpdateUser(uuid, name, address)
+
+ coVerify(exactly = 1) { userDao.getUserByUuid(uuid) }
+ coVerify(exactly = 1) {
+ userDao.insertUser(
+ match {
+ it.uuid == uuid && it.name == name && it.address == address && it.lastSeen == null
+ }
+ )
+ }
+ coVerify(exactly = 0) { userDao.updateUser(any()) }
+ }
+
+ @Test
+ fun insertOrUpdateUser_whenExistingUser_updatesExistingUser() = runTest {
+ val uuid = "u2"
+ val old = UserEntity(uuid = uuid, name = "Old", address = "10.0.0.2", lastSeen = 123L)
+ val newName = "New"
+ val newAddress = "10.0.0.99"
+
+ coEvery { userDao.getUserByUuid(uuid) } returns old
+ coEvery { userDao.updateUser(any()) } returns Unit
+
+ repo.insertOrUpdateUser(uuid, newName, newAddress)
+
+ coVerify(exactly = 1) { userDao.getUserByUuid(uuid) }
+ coVerify(exactly = 0) { userDao.insertUser(any()) }
+ coVerify(exactly = 1) {
+ userDao.updateUser(
+ match {
+ it.uuid == uuid && it.name == newName && it.address == newAddress && it.lastSeen == old.lastSeen
+ }
+ )
+ }
+ }
+
+ @Test
+ fun insertOrUpdateUser_whenExistingUser_updatesAndCanNullOutAddress() = runTest {
+ val uuid = "u3"
+ val old = UserEntity(uuid = uuid, name = "Old", address = "10.0.0.3", lastSeen = null)
+ val newName = "New"
+
+ coEvery { userDao.getUserByUuid(uuid) } returns old
+ coEvery { userDao.updateUser(any()) } returns Unit
+
+ repo.insertOrUpdateUser(uuid, newName, null)
+
+ coVerify(exactly = 1) {
+ userDao.updateUser(
+ match {
+ it.uuid == uuid && it.name == newName && it.address == null && it.lastSeen == null
+ }
+ )
+ }
+ }
+
+ @Test
+ fun getUserByIp_delegatesToDao() = runTest {
+ val ip = "10.0.0.4"
+ val entity = UserEntity(uuid = "u4", name = "Bob", address = ip)
+
+ coEvery { userDao.getUserByIp(ip) } returns entity
+
+ val got = repo.getUserByIp(ip)
+ assertEquals(entity, got)
+ coVerify(exactly = 1) { userDao.getUserByIp(ip) }
+ }
+
+ @Test
+ fun getUser_delegatesToDao() = runTest {
+ val uuid = "u5"
+ val entity = UserEntity(uuid = uuid, name = "Cara", address = null)
+
+ coEvery { userDao.getUserByUuid(uuid) } returns entity
+
+ val got = repo.getUser(uuid)
+ assertEquals(entity, got)
+ coVerify(exactly = 1) { userDao.getUserByUuid(uuid) }
+ }
+
+ @Test
+ fun getUser_whenNotFound_returnsNull() = runTest {
+ val uuid = "missing"
+ coEvery { userDao.getUserByUuid(uuid) } returns null
+
+ val got = repo.getUser(uuid)
+ assertNull(got)
+ coVerify(exactly = 1) { userDao.getUserByUuid(uuid) }
+ }
+
+ @Test
+ fun getAllConnectedUsers_delegatesToDao() = runTest {
+ val list = listOf(
+ UserEntity(uuid = "u1", name = "A", address = "10.0.0.1"),
+ UserEntity(uuid = "u2", name = "B", address = "10.0.0.2")
+ )
+
+ coEvery { userDao.getAllConnectedUsers() } returns list
+
+ val got = repo.getAllConnectedUsers()
+ assertEquals(list, got)
+ coVerify(exactly = 1) { userDao.getAllConnectedUsers() }
+ }
+
+ @Test
+ fun getAllUsers_delegatesToDao() = runTest {
+ val list = listOf(
+ UserEntity(uuid = "u1", name = "A", address = null),
+ UserEntity(uuid = "u2", name = "B", address = "10.0.0.2")
+ )
+
+ coEvery { userDao.getAllUsers() } returns list
+
+ val got = repo.getAllUsers()
+ assertEquals(list, got)
+ coVerify(exactly = 1) { userDao.getAllUsers() }
+ }
+
+ @Test
+ fun hasUser_delegatesToDao_true() = runTest {
+ val uuid = "uTrue"
+ coEvery { userDao.hasWithID(uuid) } returns true
+
+ val got = repo.hasUser(uuid)
+ assertEquals(true, got)
+ coVerify(exactly = 1) { userDao.hasWithID(uuid) }
+ }
+
+ @Test
+ fun hasUser_delegatesToDao_false() = runTest {
+ val uuid = "uFalse"
+ coEvery { userDao.hasWithID(uuid) } returns false
+
+ val got = repo.hasUser(uuid)
+ assertEquals(false, got)
+ coVerify(exactly = 1) { userDao.hasWithID(uuid) }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/util/NotificationHelperTest.kt b/app/src/test/java/com/greybox/projectmesh/util/NotificationHelperTest.kt
new file mode 100644
index 000000000..f44156771
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/util/NotificationHelperTest.kt
@@ -0,0 +1,71 @@
+package com.greybox.projectmesh.util
+
+import android.app.NotificationManager
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+class NotificationHelperTest {
+
+ @Test
+ @Config(sdk = [26], manifest = Config.NONE)
+ fun createNotificationChannel_onApi26Plus_createsExpectedChannel() {
+ val context = ApplicationProvider.getApplicationContext()
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ NotificationHelper.createNotificationChannel(context)
+
+ val channel = manager.getNotificationChannel("file_receive_channel")
+ assertNotNull(channel)
+ assertEquals("File Receive Notifications", channel?.name)
+ assertEquals(NotificationManager.IMPORTANCE_HIGH, channel?.importance)
+ assertEquals("Notifications for receiving file", channel?.description)
+ }
+
+ @Test
+ @Config(sdk = [25], manifest = Config.NONE)
+ fun createNotificationChannel_onApiBelow26_doesNothing() {
+ val context = ApplicationProvider.getApplicationContext()
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ NotificationHelper.createNotificationChannel(context)
+
+ val shadow = Shadows.shadowOf(manager)
+ assertTrue(shadow.allNotifications.isEmpty())
+ }
+
+ @Test
+ @Config(sdk = [29], manifest = Config.NONE)
+ fun showFileReceivedNotification_postsExpectedNotification_withIntentExtras() {
+ val context = ApplicationProvider.getApplicationContext()
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ NotificationHelper.showFileReceivedNotification(context, "report.pdf")
+
+ val shadow = Shadows.shadowOf(manager)
+ val posted = shadow.allNotifications
+ assertEquals(1, posted.size)
+
+ val notification = posted.single()
+ assertEquals("File Received", notification.extras.getCharSequence("android.title")?.toString())
+ assertEquals("Tap to view report.pdf", notification.extras.getCharSequence("android.text")?.toString())
+ assertTrue(notification.flags and android.app.Notification.FLAG_AUTO_CANCEL != 0)
+
+ val pendingIntent = notification.contentIntent
+ assertNotNull(pendingIntent)
+
+ val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
+ assertNotNull(savedIntent)
+ assertEquals("OPEN_RECEIVE_SCREEN", savedIntent?.action)
+ assertEquals("receive", savedIntent?.getStringExtra("navigateTo"))
+ assertEquals(true, savedIntent?.getBooleanExtra("from_notification", false))
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/HomeScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/HomeScreenViewModelTest.kt
new file mode 100644
index 000000000..ac96cd0d3
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/HomeScreenViewModelTest.kt
@@ -0,0 +1,259 @@
+package com.greybox.projectmesh.viewModel
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.core.app.ApplicationProvider
+import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
+import com.ustadmobile.meshrabiya.vnet.LocalNodeState
+import com.ustadmobile.meshrabiya.vnet.VirtualNode
+import com.ustadmobile.meshrabiya.vnet.wifi.ConnectBand
+import com.ustadmobile.meshrabiya.vnet.wifi.HotspotType
+import com.ustadmobile.meshrabiya.vnet.wifi.WifiConnectConfig
+import com.ustadmobile.meshrabiya.vnet.wifi.state.MeshrabiyaWifiState
+import com.ustadmobile.meshrabiya.vnet.wifi.state.WifiStationState
+import io.mockk.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.*
+import org.junit.*
+import org.junit.Assert.*
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.lang.reflect.Field
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE) // removes the "No manifest found" spam
+class HomeScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var prefs: SharedPreferences
+ private lateinit var node: AndroidVirtualNode
+ private lateinit var di: DI
+ private lateinit var stateFlow: MutableStateFlow
+
+ @Before
+ fun setUp() {
+ prefs = ApplicationProvider.getApplicationContext()
+ .getSharedPreferences("test_settings", Context.MODE_PRIVATE)
+ prefs.edit().clear().commit()
+
+ node = mockk(relaxed = true)
+
+ every { node.meshrabiyaWifiManager.is5GhzSupported } returns false
+
+ val initialStateObj = makeLocalNodeState(
+ wifiState = makeWifiState(
+ connectConfigPresent = false,
+ hotspotIsStarted = false,
+ station = WifiStationState.Status.AVAILABLE
+ ),
+ connectUri = "mesh://connect",
+ address = 7,
+ nodesOnMesh = setOf(10, 11)
+ )
+
+ stateFlow = MutableStateFlow(initialStateObj)
+ every { node.state } returns stateFlow
+
+ // return null is fine for nullable response type
+ coEvery { node.setWifiHotspotEnabled(any(), any(), any()) } returns null
+ coEvery { node.connectAsStation(any()) } just Runs
+ coEvery { node.disconnectWifiStation() } just Runs
+
+ di = DI {
+ bind(tag = "settings") with singleton { prefs }
+ bind() with singleton { node }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ clearAllMocks()
+ }
+
+ @Test
+ fun init_collectsNodeState_andUpdatesUiState() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+
+ // IMPORTANT: let init collectors run first
+ advanceUntilIdle()
+
+ val s1 = vm.uiState.first()
+ assertEquals("mesh://connect", s1.connectUri)
+ assertEquals(7, s1.localAddress)
+ assertFalse(s1.hotspotStatus)
+ assertEquals(setOf(10, 11), s1.nodesOnMesh)
+
+ stateFlow.value = makeLocalNodeState(
+ wifiState = makeWifiState(
+ connectConfigPresent = true,
+ hotspotIsStarted = true,
+ station = WifiStationState.Status.AVAILABLE
+ ),
+ connectUri = "mesh://new",
+ address = 42,
+ nodesOnMesh = setOf(99)
+ )
+ advanceUntilIdle()
+
+ val s2 = vm.uiState.first()
+ assertEquals("mesh://new", s2.connectUri)
+ assertEquals(42, s2.localAddress)
+ assertTrue(s2.hotspotStatus)
+ assertEquals(setOf(99), s2.nodesOnMesh)
+ }
+
+ @Test
+ fun init_when5GhzSupported_setsBandMenuAndDefaultBand() = runTest {
+ every { node.meshrabiyaWifiManager.is5GhzSupported } returns true
+
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+ advanceUntilIdle()
+
+ val s = vm.uiState.first()
+ assertEquals(listOf(ConnectBand.BAND_5GHZ, ConnectBand.BAND_2GHZ), s.bandMenu)
+ assertEquals(ConnectBand.BAND_5GHZ, s.band)
+ }
+
+ @Test
+ fun saveConcurrencyKnown_andSupported_updateFlows_andPrefs() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+
+ vm.saveConcurrencyKnown(true)
+ vm.saveConcurrencySupported(false)
+
+ assertTrue(vm.concurrencyKnown.value)
+ assertFalse(vm.concurrencySupported.value)
+
+ assertTrue(prefs.getBoolean("concurrency_known", false))
+ assertFalse(prefs.getBoolean("concurrency_supported", true))
+ }
+
+ @Test
+ fun prefsListener_updatesFlows_whenPrefsChange() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+
+ prefs.edit().putBoolean("concurrency_known", true).commit()
+ prefs.edit().putBoolean("concurrency_supported", false).commit()
+
+ advanceUntilIdle()
+
+ assertTrue(vm.concurrencyKnown.value)
+ assertFalse(vm.concurrencySupported.value)
+ }
+
+ @Test
+ fun onConnectBandChanged_updatesUiState() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+ vm.onConnectBandChanged(ConnectBand.BAND_2GHZ)
+ advanceUntilIdle()
+
+ assertEquals(ConnectBand.BAND_2GHZ, vm.uiState.first().band)
+ }
+
+ @Test
+ fun onSetHotspotTypeToCreate_updatesUiState() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+ vm.onSetHotspotTypeToCreate(HotspotType.LOCALONLY_HOTSPOT)
+ advanceUntilIdle()
+
+ assertEquals(HotspotType.LOCALONLY_HOTSPOT, vm.uiState.first().hotspotTypeToCreate)
+ }
+
+ @Test
+ fun onClickDisconnectStation_callsNodeDisconnect() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+ vm.onClickDisconnectStation()
+ advanceUntilIdle()
+
+ coVerify { node.disconnectWifiStation() }
+ }
+
+ @Test
+ fun onConnectWifi_callsConnectAsStation() = runTest {
+ val vm = HomeScreenViewModel(di, SavedStateHandle())
+
+ // Still ok to create dummy config, but DO NOT verify by equality (hashCode triggers NPE)
+ val cfg = unsafeInstance()
+
+ vm.onConnectWifi(cfg)
+ advanceUntilIdle()
+
+ // FIX: verify call happened, don't force MockK to hash/compare cfg
+ coVerify { node.connectAsStation(any()) }
+ }
+
+ // ---------------- Helpers ----------------
+
+ private fun makeLocalNodeState(
+ wifiState: MeshrabiyaWifiState,
+ connectUri: String,
+ address: Int,
+ nodesOnMesh: Set
+ ): LocalNodeState {
+ val st = mockk(relaxed = true)
+
+ every { st.wifiState } returns wifiState
+ every { st.connectUri } returns connectUri
+ every { st.address } returns address
+
+ val originatorMap: Map =
+ nodesOnMesh.associateWith { unsafeInstance() }
+
+ every { st.originatorMessages } returns originatorMap
+ return st
+ }
+
+ private fun makeWifiState(
+ connectConfigPresent: Boolean,
+ hotspotIsStarted: Boolean,
+ station: WifiStationState.Status
+ ): MeshrabiyaWifiState {
+ val wifi = mockk(relaxed = true)
+
+ val cfg: WifiConnectConfig? =
+ if (connectConfigPresent) unsafeInstance() else null
+ every { wifi.connectConfig } returns cfg
+
+ every { wifi.hotspotIsStarted } returns hotspotIsStarted
+
+ val stationState = mockk(relaxed = true)
+ every { stationState.status } returns station
+ every { wifi.wifiStationState } returns stationState
+
+ return wifi
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private inline fun unsafeInstance(): T {
+ val unsafe = getUnsafe()
+ val allocate = unsafe.javaClass.getMethod("allocateInstance", Class::class.java)
+ return allocate.invoke(unsafe, T::class.java) as T
+ }
+
+ private fun getUnsafe(): Any {
+ val clazz = try {
+ Class.forName("sun.misc.Unsafe")
+ } catch (_: ClassNotFoundException) {
+ Class.forName("jdk.internal.misc.Unsafe")
+ }
+ val f: Field = clazz.getDeclaredField("theUnsafe")
+ f.isAccessible = true
+ return f.get(null)
+ }
+}
+
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/LogScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/LogScreenViewModelTest.kt
new file mode 100644
index 000000000..5c4ec448c
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/LogScreenViewModelTest.kt
@@ -0,0 +1,107 @@
+package com.greybox.projectmesh.viewModel
+
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.greybox.projectmesh.MNetLoggerAndroid
+import com.ustadmobile.meshrabiya.log.LogLine
+import com.ustadmobile.meshrabiya.log.MNetLogger
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.test.resetMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class LogScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var di: DI
+ private lateinit var logger: MNetLoggerAndroid
+
+ // We'll drive emissions into the VM via this flow.
+ private lateinit var recentLogsFlow: MutableSharedFlow>
+
+ @Before
+ fun setUp() {
+ recentLogsFlow = MutableSharedFlow(replay = 1)
+
+ // Must be an actual MNetLoggerAndroid at runtime because VM does:
+ // di.direct.instance() as MNetLoggerAndroid
+ logger = mockk(relaxed = true)
+
+ every { logger.recentLogs } returns recentLogsFlow
+
+ di = DI {
+ // Bind under MNetLogger (what the VM requests) but return the SAME object
+ // whose runtime type is MNetLoggerAndroid, so the cast succeeds.
+ bind() with singleton { logger as MNetLogger }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ clearAllMocks()
+ }
+
+ @Test
+ fun uiState_startsWithEmptyLogs() = runTest {
+ val vm = LogScreenViewModel(di, SavedStateHandle())
+
+ vm.uiState.test {
+ val first = awaitItem()
+ assertEquals(emptyList(), first.logs)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun uiState_updatesWhenRecentLogsEmits() = runTest {
+ val vm = LogScreenViewModel(di, SavedStateHandle())
+
+ val l1 = mockk(relaxed = true)
+ val l2 = mockk(relaxed = true)
+ val payload = listOf(l1, l2)
+
+ vm.uiState.test {
+ // initial
+ val first = awaitItem()
+ assertEquals(0, first.logs.size)
+
+ // emit new logs
+ recentLogsFlow.emit(payload)
+ advanceUntilIdle()
+
+ val second = awaitItem()
+ assertEquals(payload, second.logs)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
+
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/NetworkScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/NetworkScreenViewModelTest.kt
new file mode 100644
index 000000000..835cf493c
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/NetworkScreenViewModelTest.kt
@@ -0,0 +1,192 @@
+package com.greybox.projectmesh.viewModel
+
+import androidx.lifecycle.SavedStateHandle
+import com.greybox.projectmesh.DeviceStatusManager
+import com.greybox.projectmesh.server.AppServer
+import com.greybox.projectmesh.testing.TestDeviceEntry
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+import com.ustadmobile.meshrabiya.ext.addressToByteArray
+import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
+import com.ustadmobile.meshrabiya.vnet.LocalNodeState
+import com.ustadmobile.meshrabiya.vnet.VirtualNode
+import com.ustadmobile.meshrabiya.vnet.wifi.WifiConnectConfig
+import com.ustadmobile.meshrabiya.vnet.wifi.state.MeshrabiyaWifiState
+import com.ustadmobile.meshrabiya.vnet.wifi.state.WifiStationState
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import io.mockk.coVerify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.InetAddress
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class NetworkScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
+
+ private lateinit var node: AndroidVirtualNode
+ private lateinit var appServer: AppServer
+ private lateinit var di: DI
+
+ private lateinit var nodeStateFlow: MutableStateFlow
+
+ @Before
+ fun setUp() {
+ node = mockk(relaxed = true)
+ appServer = mockk(relaxed = true)
+
+ mockkObject(DeviceStatusManager)
+ every { DeviceStatusManager.updateDeviceStatus(any(), any(), any()) } just Runs
+ every { DeviceStatusManager.handleNetworkDisconnect(any()) } just Runs
+
+ mockkObject(TestDeviceEntry)
+ val testMsg = mockk(relaxed = true)
+ every { TestDeviceEntry.createTestEntry() } returns (1234 to testMsg)
+
+ nodeStateFlow = MutableStateFlow(
+ makeNodeState(
+ originators = mapOf(
+ 1 to mockk(relaxed = true),
+ 2 to mockk(relaxed = true),
+ ),
+ connecting = true,
+ ssid = "MyWifi"
+ )
+ )
+
+ every { node.state } returns nodeStateFlow
+
+ di = DI {
+ bind() with singleton { node }
+ bind() with singleton { appServer }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(DeviceStatusManager)
+ unmockkObject(TestDeviceEntry)
+ clearAllMocks()
+ }
+
+ @Test
+ fun init_setsConnectingSsid_andIncludesTestDevice_andUpdatesStatuses() = runTest {
+ val vm = NetworkScreenViewModel(di, SavedStateHandle())
+ advanceUntilIdle()
+
+ val state = vm.uiState.first()
+
+ assertEquals("MyWifi", state.connectingInProgressSsid)
+ assertTrue(state.allNodes.keys.containsAll(setOf(1, 2, 1234)))
+
+ state.allNodes.keys.forEach { addrInt ->
+ val ip = InetAddress.getByAddress(addrInt.addressToByteArray()).hostAddress
+ verify { DeviceStatusManager.updateDeviceStatus(ip, true, verified = false) }
+ }
+ }
+
+ @Test
+ fun whenNotConnecting_connectingInProgressSsidBecomesNull() = runTest {
+ val vm = NetworkScreenViewModel(di, SavedStateHandle())
+ advanceUntilIdle()
+
+ nodeStateFlow.value = makeNodeState(
+ originators = mapOf(9 to mockk(relaxed = true)),
+ connecting = false,
+ ssid = "ShouldNotAppear"
+ )
+ advanceUntilIdle()
+
+ val state = vm.uiState.first()
+ assertNull(state.connectingInProgressSsid)
+ }
+
+ @Test
+ fun whenNodeDisappears_callsHandleNetworkDisconnect() = runTest {
+ val vm = NetworkScreenViewModel(di, SavedStateHandle())
+ advanceUntilIdle()
+
+ nodeStateFlow.value = makeNodeState(
+ originators = mapOf(2 to mockk(relaxed = true)), // node 1 disappeared
+ connecting = false,
+ ssid = null
+ )
+ advanceUntilIdle()
+
+ val ip1 = InetAddress.getByAddress(1.addressToByteArray()).hostAddress
+ verify { DeviceStatusManager.handleNetworkDisconnect(ip1) }
+ }
+
+ //@Test
+ /* fun getDeviceName_callsAppServerSendDeviceName() = runTest {
+ val vm = NetworkScreenViewModel(di, SavedStateHandle())
+ advanceUntilIdle()
+
+ val addr = 0x0A000001 // 10.0.0.1
+ NetworkScreenViewModel::class.java
+ .getDeclaredMethod("getDeviceName", Int::class.javaPrimitiveType)
+ .apply { isAccessible = true }
+ .invoke(vm, addr)
+ advanceUntilIdle()
+
+ val inet = InetAddress.getByAddress(addr.addressToByteArray())
+
+ io.mockk.coVerify { appServer.sendDeviceName(inet) }
+ }
+*/
+ private fun makeNodeState(
+ originators: Map,
+ connecting: Boolean,
+ ssid: String?
+ ): LocalNodeState {
+ val nodeState = mockk(relaxed = true)
+
+ every { nodeState.originatorMessages } returns originators
+
+ val wifiState = mockk(relaxed = true)
+ val stationState = mockk(relaxed = true)
+
+ every {
+ stationState.status
+ } returns if (connecting) WifiStationState.Status.CONNECTING else WifiStationState.Status.AVAILABLE
+
+ if (ssid != null) {
+ val cfg = mockk(relaxed = true)
+ every { cfg.ssid } returns ssid
+ every { stationState.config } returns cfg
+ } else {
+ every { stationState.config } returns null
+ }
+
+ every { wifiState.wifiStationState } returns stationState
+ every { nodeState.wifiState } returns wifiState
+
+ return nodeState
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/OnboardingViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/OnboardingViewModelTest.kt
new file mode 100644
index 000000000..8876891cd
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/OnboardingViewModelTest.kt
@@ -0,0 +1,143 @@
+package com.greybox.projectmesh.viewModel
+
+import android.content.SharedPreferences
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+import com.greybox.projectmesh.user.UserEntity
+import com.greybox.projectmesh.user.UserRepository
+import io.mockk.*
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class OnboardingViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
+
+ private lateinit var repo: UserRepository
+ private lateinit var prefs: SharedPreferences
+ private lateinit var editor: SharedPreferences.Editor
+
+ @Before
+ fun setUp() {
+ repo = mockk(relaxed = true)
+ prefs = mockk(relaxed = true)
+ editor = mockk(relaxed = true)
+
+ every { prefs.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ every { editor.putBoolean(any(), any()) } returns editor
+ every { editor.apply() } just Runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ clearAllMocks()
+ }
+
+ @Test
+ fun onUsernameChange_updatesUiState() = runTest {
+ val vm = OnboardingViewModel(repo, prefs, "10.0.0.1")
+ vm.onUsernameChange("Jai")
+ advanceUntilIdle()
+ assertEquals("Jai", vm.uiState.value.username)
+ }
+
+ @Test
+ fun handleFirstTimeSetup_whenUuidMissing_generatesUuid_savesPrefs_insertsUser_andCallsOnComplete() = runTest {
+ every { prefs.getString("UUID", null) } returns null
+
+ val vm = OnboardingViewModel(repo, prefs, "10.0.0.1")
+ vm.onUsernameChange("Jai")
+
+ var completed = false
+ vm.handleFirstTimeSetup { completed = true }
+ advanceUntilIdle()
+
+ assertTrue(completed)
+
+ // UUID should be generated + stored (we don't care what exact value is)
+ verify { editor.putString("UUID", match { it.isNotBlank() }) }
+
+ // Repository called with same UUID value that was stored
+ val storedUuid = slot()
+ verify { editor.putString("UUID", capture(storedUuid)) }
+
+ coVerify {
+ repo.insertOrUpdateUser(
+ uuid = storedUuid.captured,
+ name = "Jai",
+ address = "10.0.0.1"
+ )
+ }
+
+ verify {
+ editor.putString("device_name", "Jai")
+ editor.putBoolean("hasRunBefore", true)
+ editor.apply()
+ }
+ }
+
+ @Test
+ fun handleFirstTimeSetup_whenUuidExists_usesExistingUuid_andDoesNotOverwriteUuid() = runTest {
+ val existingUuid = "22222222-2222-2222-2222-222222222222"
+ every { prefs.getString("UUID", null) } returns existingUuid
+
+ val vm = OnboardingViewModel(repo, prefs, "10.0.0.9")
+ vm.onUsernameChange("Alice")
+
+ var completed = false
+ vm.handleFirstTimeSetup { completed = true }
+ advanceUntilIdle()
+
+ assertTrue(completed)
+
+ // Should NOT rewrite UUID when already present
+ verify(exactly = 0) { editor.putString("UUID", any()) }
+
+ coVerify {
+ repo.insertOrUpdateUser(
+ uuid = existingUuid,
+ name = "Alice",
+ address = "10.0.0.9"
+ )
+ }
+
+ verify {
+ editor.putString("device_name", "Alice")
+ editor.putBoolean("hasRunBefore", true)
+ editor.apply()
+ }
+ }
+
+ @Test
+ fun blankUsernameGenerator_picksNextGuestNumber() = runTest {
+ val u1 = mockk(relaxed = true).also { every { it.name } returns "Guest1" }
+ val u2 = mockk(relaxed = true).also { every { it.name } returns "Guest2" }
+ val u3 = mockk(relaxed = true).also { every { it.name } returns "Bob" }
+ val u4 = mockk(relaxed = true).also { every { it.name } returns "Guest10" }
+
+ coEvery { repo.getAllUsers() } returns listOf(u1, u2, u3, u4)
+
+ val vm = OnboardingViewModel(repo, prefs, "10.0.0.1")
+
+ var result: String? = null
+ vm.blankUsernameGenerator { result = it }
+ advanceUntilIdle()
+
+ assertEquals("Guest11", result)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/PingScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/PingScreenViewModelTest.kt
new file mode 100644
index 000000000..f29a7a01b
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/PingScreenViewModelTest.kt
@@ -0,0 +1,102 @@
+package com.greybox.projectmesh.viewModel
+
+import android.os.Looper
+import androidx.lifecycle.SavedStateHandle
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.server.AppServer
+import com.greybox.projectmesh.user.UserEntity
+import com.greybox.projectmesh.user.UserRepository
+import com.ustadmobile.meshrabiya.ext.requireAddressAsInt
+import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
+import com.ustadmobile.meshrabiya.vnet.LocalNodeState
+import com.ustadmobile.meshrabiya.vnet.VirtualNode
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.net.InetAddress
+
+class PingScreenViewModelTest {
+
+ private val mainDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+ Dispatchers.setMain(mainDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun new_message_added_duplicate_ignored() = runBlocking {
+ val virtualAddress = InetAddress.getByName("192.168.0.42")
+ val addrKey = virtualAddress.requireAddressAsInt()
+
+ val userRepo = mockk()
+ val userEntity = mockk()
+ every { userEntity.name } returns "Device42"
+ coEvery { userRepo.getUserByIp(virtualAddress.hostAddress) } returns userEntity
+ GlobalApp.GlobalUserRepo.userRepository = userRepo
+
+ val initialNodeState = mockk(relaxed = true)
+ val stateFlow: MutableStateFlow = MutableStateFlow(initialNodeState)
+
+ val node = mockk()
+ every { node.state } returns stateFlow
+
+ val appServer = mockk(relaxed = true)
+
+ val di = DI {
+ bind() with singleton { node }
+ bind() with singleton { appServer }
+ }
+
+ val viewModel = PingScreenViewModel(
+ di = di,
+ savedStateHandle = SavedStateHandle(),
+ virtualAddress = virtualAddress
+ )
+
+ val msg1 = mockk()
+ every { msg1.timeReceived } returns 100L
+
+ val msgDup = mockk()
+ every { msgDup.timeReceived } returns 100L
+
+ val stateWithMsg1 = mockk()
+ every { stateWithMsg1.originatorMessages } returns mapOf(addrKey to msg1)
+
+ val stateWithDup = mockk()
+ every { stateWithDup.originatorMessages } returns mapOf(addrKey to msgDup)
+
+ stateFlow.value = stateWithMsg1
+ mainDispatcher.scheduler.advanceUntilIdle()
+ val afterFirst = viewModel.uiState.first { it.allOriginatorMessages.size == 1 }
+
+ stateFlow.value = stateWithDup
+ mainDispatcher.scheduler.advanceUntilIdle()
+ val afterDup = viewModel.uiState.first { it.allOriginatorMessages.size == 1 }
+
+ assertEquals(1, afterFirst.allOriginatorMessages.size)
+ assertEquals(1, afterDup.allOriginatorMessages.size)
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/ReceiveScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/ReceiveScreenViewModelTest.kt
new file mode 100644
index 000000000..20695a855
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/ReceiveScreenViewModelTest.kt
@@ -0,0 +1,149 @@
+package com.greybox.projectmesh.viewModel
+
+import android.os.Looper
+import androidx.lifecycle.SavedStateHandle
+import com.greybox.projectmesh.GlobalApp
+import com.greybox.projectmesh.server.AppServer
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import java.io.File
+
+class ReceiveScreenViewModelTest {
+
+ private val mainDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+ Dispatchers.setMain(mainDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun uiState_updates_when_incomingTransfers_emits() = runTest {
+ val incomingFlow = MutableStateFlow>(emptyList())
+
+ val appServer = mockk(relaxed = true)
+ every { appServer.incomingTransfers } returns incomingFlow
+
+ val receiveDir = File(createTempDir(prefix = "recv"), "inbox").apply { mkdirs() }
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind(tag = GlobalApp.TAG_RECEIVE_DIR) with singleton { receiveDir }
+ }
+
+ val vm = ReceiveScreenViewModel(di, SavedStateHandle())
+
+ val t1 = mockk(relaxed = true)
+ val t2 = mockk(relaxed = true)
+
+ incomingFlow.value = listOf(t1, t2)
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ val state = vm.uiState.first { it.incomingTransfers.size == 2 }
+ assertEquals(2, state.incomingTransfers.size)
+ }
+
+ @Test
+ fun onAccept_creates_dir_and_calls_acceptIncomingTransfer_with_destination_file() = runTest {
+ val incomingFlow = MutableStateFlow>(emptyList())
+
+ val appServer = mockk(relaxed = true)
+ every { appServer.incomingTransfers } returns incomingFlow
+ coEvery { appServer.acceptIncomingTransfer(any(), any()) } returns Unit
+
+ val receiveDir = File(createTempDir(prefix = "recv"), "inbox") // should not exist yet
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind(tag = GlobalApp.TAG_RECEIVE_DIR) with singleton { receiveDir }
+ }
+
+ val vm = ReceiveScreenViewModel(di, SavedStateHandle())
+
+ val transfer = mockk()
+ every { transfer.name } returns "file.bin"
+
+ vm.onAccept(transfer)
+
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ coVerify(exactly = 1) {
+ appServer.acceptIncomingTransfer(
+ eq(transfer),
+ match { it.path == File(receiveDir, "file.bin").path }
+ )
+ }
+ }
+
+ @Test
+ fun onDecline_calls_onDeclineIncomingTransfer() = runTest {
+ val incomingFlow = MutableStateFlow>(emptyList())
+
+ val appServer = mockk(relaxed = true)
+ every { appServer.incomingTransfers } returns incomingFlow
+
+ val receiveDir = File(createTempDir(prefix = "recv"), "inbox").apply { mkdirs() }
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind(tag = GlobalApp.TAG_RECEIVE_DIR) with singleton { receiveDir }
+ }
+
+ val vm = ReceiveScreenViewModel(di, SavedStateHandle())
+
+ val transfer = mockk(relaxed = true)
+
+ vm.onDecline(transfer)
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ coVerify(exactly = 1) { appServer.onDeclineIncomingTransfer(eq(transfer)) }
+ }
+
+ @Test
+ fun onDelete_calls_onDeleteIncomingTransfer() = runTest {
+ val incomingFlow = MutableStateFlow>(emptyList())
+
+ val appServer = mockk(relaxed = true)
+ every { appServer.incomingTransfers } returns incomingFlow
+
+ val receiveDir = File(createTempDir(prefix = "recv"), "inbox").apply { mkdirs() }
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind(tag = GlobalApp.TAG_RECEIVE_DIR) with singleton { receiveDir }
+ }
+
+ val vm = ReceiveScreenViewModel(di, SavedStateHandle())
+
+ val transfer = mockk(relaxed = true)
+
+ vm.onDelete(transfer)
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ coVerify(exactly = 1) { appServer.onDeleteIncomingTransfer(eq(transfer)) }
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModelTest.kt
new file mode 100644
index 000000000..36ad62d70
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/SelectDestNodeScreenViewModelTest.kt
@@ -0,0 +1,137 @@
+package com.greybox.projectmesh.viewModel
+
+import android.net.Uri
+import android.os.Looper
+import androidx.lifecycle.SavedStateHandle
+import com.ustadmobile.meshrabiya.ext.addressToDotNotation
+import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
+import com.ustadmobile.meshrabiya.vnet.LocalNodeState
+import com.ustadmobile.meshrabiya.vnet.VirtualNode
+import com.greybox.projectmesh.server.AppServer
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+
+class SelectDestNodeScreenViewModelTest {
+
+ private val mainDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+ Dispatchers.setMain(mainDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun init_sets_uris_and_collect_updates_allNodes() = runTest {
+ val uri1 = mockk(relaxed = true)
+ val uri2 = mockk(relaxed = true)
+ val sendUris = listOf(uri1, uri2)
+
+ val appServer = mockk(relaxed = true)
+
+ val initialState = mockk(relaxed = true)
+ val nodeStateFlow = MutableStateFlow(initialState)
+
+ val node = mockk()
+ every { node.state } returns nodeStateFlow
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind() with singleton { node }
+ }
+
+ val vm = SelectDestNodeScreenViewModel(
+ di = di,
+ savedStateHandle = SavedStateHandle(),
+ sendUris = sendUris,
+ popBackWhenDone = {}
+ )
+
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ val s0 = vm.uiState.first { it.uris.size == 2 }
+ assertEquals(2, s0.uris.size)
+
+ val msg = mockk(relaxed = true)
+ val newMap = mapOf(123 to msg)
+
+ val updatedState = mockk()
+ every { updatedState.originatorMessages } returns newMap
+
+ nodeStateFlow.value = updatedState
+ mainDispatcher.scheduler.advanceUntilIdle()
+
+ val s1 = vm.uiState.first { it.allNodes.size == 1 }
+ assertEquals(1, s1.allNodes.size)
+ }
+}
+
+ /* @Test
+ fun onClickReceiver_updates_contacting_and_pops_when_any_transfer_succeeds() = runTest {
+ val uri1 = mockk(relaxed = true)
+ val uri2 = mockk(relaxed = true)
+ val sendUris = listOf(uri1, uri2)
+
+ val appServer = mockk(relaxed = true)
+ // ensure no exception inside try{} so it returns true and pops
+ coEvery { appServer.addOutgoingTransfer(any(), any()) } returns mockk(relaxed = true)
+
+ val node = mockk()
+ every { node.state } returns MutableStateFlow(mockk(relaxed = true))
+
+ val di = DI {
+ bind() with singleton { appServer }
+ bind() with singleton { node }
+ }
+
+ var popped = false
+
+ val vm = SelectDestNodeScreenViewModel(
+ di = di,
+ savedStateHandle = SavedStateHandle(),
+ sendUris = sendUris,
+ popBackWhenDone = { popped = true }
+ )
+
+ val address = 0xC0A80001.toInt() // 192.168.0.1
+ vm.onClickReceiver(address)
+
+ val state = vm.uiState.first { it.contactingInProgressDevice != null }
+ assertEquals(address.addressToDotNotation(), state.contactingInProgressDevice)
+
+ withTimeout(5_000) {
+ while (!popped) {
+ mainDispatcher.scheduler.advanceUntilIdle()
+ delay(10)
+ }
+ }
+
+ assertTrue(popped)
+ }
+}
+ */
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/SendScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/SendScreenViewModelTest.kt
new file mode 100644
index 000000000..9d3797496
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/SendScreenViewModelTest.kt
@@ -0,0 +1,142 @@
+package com.greybox.projectmesh.viewModel
+
+import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import com.greybox.projectmesh.server.AppServer
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.net.InetAddress
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class SendScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var appServer: AppServer
+ private lateinit var outgoingFlow: MutableStateFlow>
+ private lateinit var di: DI
+
+ @Before
+ fun setUp() {
+ outgoingFlow = MutableStateFlow(emptyList())
+ appServer = mockk(relaxed = true)
+
+ every { appServer.outgoingTransfers } returns outgoingFlow
+ coEvery { appServer.removeOutgoingTransfer(any()) } returns Unit
+
+ di = DI {
+ bind() with singleton { appServer }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ clearAllMocks()
+ }
+
+ @Test
+ fun init_collectsOutgoingTransfers_andUpdatesUiState() = runTest {
+ val vm = SendScreenViewModel(di, SavedStateHandle()) { }
+
+ advanceUntilIdle()
+ assertEquals(emptyList(), latest(vm))
+
+ val t1 = outgoingTransfer(id = 1, name = "a.txt")
+ val t2 = outgoingTransfer(id = 2, name = "b.txt")
+ outgoingFlow.value = listOf(t1, t2)
+
+ advanceUntilIdle()
+ assertEquals(listOf(t1, t2), latest(vm))
+ }
+
+ @Test
+ fun onFileChosen_callsCallbackWithUris() = runTest {
+ var got: List? = null
+ val vm = SendScreenViewModel(di, SavedStateHandle()) { uris -> got = uris }
+
+ val uris = listOf(Uri.parse("content://test/one"), Uri.parse("content://test/two"))
+ vm.onFileChosen(uris)
+
+ assertEquals(uris, got)
+ }
+
+ @Test
+ fun onDelete_callsRemoveOutgoingTransferWithId() = runTest {
+ val vm = SendScreenViewModel(di, SavedStateHandle()) { }
+ val t = outgoingTransfer(id = 77, name = "gone.txt")
+
+ vm.onDelete(t)
+ advanceUntilIdle()
+
+ coVerify(exactly = 1) { appServer.removeOutgoingTransfer(77) }
+ }
+
+ @Test
+ fun uiState_emitsUpdates_whenOutgoingTransfersChangesMultipleTimes() = runTest {
+ val vm = SendScreenViewModel(di, SavedStateHandle()) { }
+ advanceUntilIdle()
+
+ val t1 = outgoingTransfer(id = 1, name = "a.txt")
+ val t2 = outgoingTransfer(id = 2, name = "b.txt")
+ val t3 = outgoingTransfer(id = 3, name = "c.txt")
+
+ vm.uiState.test {
+ awaitItem()
+ outgoingFlow.value = listOf(t1)
+ advanceUntilIdle()
+ assertEquals(listOf(t1), awaitItem().outgoingTransfers)
+
+ outgoingFlow.value = listOf(t1, t2)
+ advanceUntilIdle()
+ assertEquals(listOf(t1, t2), awaitItem().outgoingTransfers)
+
+ outgoingFlow.value = listOf(t3)
+ advanceUntilIdle()
+ assertEquals(listOf(t3), awaitItem().outgoingTransfers)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ private suspend fun latest(vm: SendScreenViewModel): List {
+ var out: List = emptyList()
+ vm.uiState.test {
+ out = awaitItem().outgoingTransfers
+ cancelAndConsumeRemainingEvents()
+ }
+ return out
+ }
+
+ private fun outgoingTransfer(id: Int, name: String): AppServer.OutgoingTransferInfo {
+ return AppServer.OutgoingTransferInfo(
+ id = id,
+ name = name,
+ uri = Uri.parse("content://test/$id"),
+ toHost = InetAddress.getByName("192.168.1.${10 + id}"),
+ size = 1234 + id
+ )
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/SettingsScreenViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/SettingsScreenViewModelTest.kt
new file mode 100644
index 000000000..808db2518
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/SettingsScreenViewModelTest.kt
@@ -0,0 +1,148 @@
+package com.greybox.projectmesh.viewModel
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Build
+import android.os.Environment
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.core.app.ApplicationProvider
+import com.greybox.projectmesh.testutil.MainDispatcherRule
+import com.greybox.projectmesh.ui.theme.AppTheme
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.kodein.di.DI
+import org.kodein.di.bind
+import org.kodein.di.singleton
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class SettingsScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var prefs: SharedPreferences
+ private lateinit var di: DI
+
+ @Before
+ fun setUp() {
+ prefs = ApplicationProvider.getApplicationContext()
+ .getSharedPreferences("test_settings_vm", Context.MODE_PRIVATE)
+ prefs.edit().clear().commit()
+
+ di = DI {
+ bind(tag = "settings") with singleton { prefs }
+ }
+ }
+
+ @After
+ fun tearDown() {
+ prefs.edit().clear().commit()
+ }
+
+ @Test
+ fun init_whenPrefsEmpty_loadsDefaults() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ assertEquals(AppTheme.SYSTEM, vm.theme.value)
+ assertEquals("System", vm.lang.value)
+ assertEquals(Build.MODEL, vm.deviceName.value)
+ assertFalse(vm.autoFinish.value)
+ val expectedDefaultFolder =
+ "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/Project Mesh"
+ assertEquals(expectedDefaultFolder, vm.saveToFolder.value)
+ }
+
+ @Test
+ fun init_whenPrefsPopulated_loadsSavedValues() = runTest {
+ val expectedDefaultFolder =
+ "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/Project Mesh"
+ prefs.edit()
+ .putString("app_theme", AppTheme.DARK.name)
+ .putString("language", "es")
+ .putString("device_name", "MyPhone")
+ .putBoolean("auto_finish", true)
+ .putString("save_to_folder", "$expectedDefaultFolder/custom")
+ .commit()
+
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ assertEquals(AppTheme.DARK, vm.theme.value)
+ assertEquals("es", vm.lang.value)
+ assertEquals("MyPhone", vm.deviceName.value)
+ assertTrue(vm.autoFinish.value)
+ assertEquals("$expectedDefaultFolder/custom", vm.saveToFolder.value)
+ }
+
+ @Test
+ fun saveTheme_updatesFlowAndPrefs() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ vm.saveTheme(AppTheme.LIGHT)
+
+ assertEquals(AppTheme.LIGHT, vm.theme.value)
+ assertEquals(AppTheme.LIGHT.name, prefs.getString("app_theme", null))
+ }
+
+ @Test
+ fun saveLang_updatesFlowAndPrefs() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ vm.saveLang("ko")
+
+ assertEquals("ko", vm.lang.value)
+ assertEquals("ko", prefs.getString("language", null))
+ }
+
+ @Test
+ fun saveDeviceName_updatesFlowAndPrefs() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ vm.saveDeviceName("Device X")
+
+ assertEquals("Device X", vm.deviceName.value)
+ assertEquals("Device X", prefs.getString("device_name", null))
+ }
+
+ @Test
+ fun saveAutoFinish_updatesFlowAndPrefs() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ vm.saveAutoFinish(true)
+
+ assertTrue(vm.autoFinish.value)
+ assertTrue(prefs.getBoolean("auto_finish", false))
+ }
+
+ @Test
+ fun saveSaveToFolder_updatesFlowAndPrefs() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+ val folder = "/storage/emulated/0/Download/Project Mesh"
+
+ vm.saveSaveToFolder(folder)
+
+ assertEquals(folder, vm.saveToFolder.value)
+ assertEquals(folder, prefs.getString("save_to_folder", null))
+ }
+
+ @Test
+ fun updateConcurrencySettings_writesBothKeys() = runTest {
+ val vm = SettingsScreenViewModel(di, SavedStateHandle())
+
+ vm.updateConcurrencySettings(concurrencyKnown = true, concurrencySupported = false)
+
+ assertTrue(prefs.getBoolean("concurrency_known", false))
+ assertFalse(prefs.getBoolean("concurrency_supported", true))
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/viewModel/SharedUriViewModelTest.kt b/app/src/test/java/com/greybox/projectmesh/viewModel/SharedUriViewModelTest.kt
new file mode 100644
index 000000000..ce7c7bf33
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/viewModel/SharedUriViewModelTest.kt
@@ -0,0 +1,48 @@
+package com.greybox.projectmesh.viewModel
+
+import android.net.Uri
+import android.os.Looper
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class SharedUriViewModelTest {
+
+ @Before
+ fun setUp() {
+ // Avoid any accidental android-main access in unit tests
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+ }
+
+ @After
+ fun tearDown() {
+ io.mockk.unmockkStatic(Looper::class)
+ }
+
+ @Test
+ fun uris_initially_empty() = runBlocking {
+ val vm = SharedUriViewModel()
+ assertEquals(emptyList(), vm.uris.first())
+ }
+
+ @Test
+ fun setUris_updates_stateflow_value() = runBlocking {
+ val vm = SharedUriViewModel()
+ val u1 = mockk(relaxed = true)
+ val u2 = mockk(relaxed = true)
+
+ vm.setUris(listOf(u1, u2))
+
+ val v = vm.uris.first()
+ assertEquals(2, v.size)
+ assertEquals(u1, v[0])
+ assertEquals(u2, v[1])
+ }
+}
diff --git a/app/src/test/java/com/greybox/projectmesh/views/HomeScreenUiLogicTest.kt b/app/src/test/java/com/greybox/projectmesh/views/HomeScreenUiLogicTest.kt
new file mode 100644
index 000000000..71d1dfc39
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/HomeScreenUiLogicTest.kt
@@ -0,0 +1,107 @@
+package com.greybox.projectmesh.views
+
+import com.ustadmobile.meshrabiya.vnet.wifi.state.WifiStationState
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+
+private object HomeScreenUiLogic {
+
+ fun isStartHotspotEnabled(
+ stationStatus: WifiStationState.Status?,
+ concurrencySupported: Boolean
+ ): Boolean {
+ return stationStatus == null ||
+ stationStatus == WifiStationState.Status.INACTIVE ||
+ concurrencySupported
+ }
+
+ fun isConnectActionEnabled(
+ hotspotStarted: Boolean,
+ concurrencySupported: Boolean
+ ): Boolean {
+ return !hotspotStarted || concurrencySupported
+ }
+
+ fun shouldShowStopHotspotButton(wifiConnectionEnabled: Boolean): Boolean = wifiConnectionEnabled
+ fun shouldShowStartHotspotButton(wifiConnectionEnabled: Boolean): Boolean = !wifiConnectionEnabled
+
+ fun shouldShowQrCode(connectUri: String?, wifiConnectionEnabled: Boolean): Boolean {
+ return connectUri != null && wifiConnectionEnabled
+ }
+}
+
+class HomeScreenUiLogicTest {
+
+ @Test
+ fun startHotspotEnabled_whenStationStatusNull_true() {
+ assertTrue(
+ HomeScreenUiLogic.isStartHotspotEnabled(
+ stationStatus = null,
+ concurrencySupported = false
+ )
+ )
+ }
+
+ @Test
+ fun startHotspotEnabled_whenStationInactive_true_evenIfNoConcurrency() {
+ assertTrue(
+ HomeScreenUiLogic.isStartHotspotEnabled(
+ stationStatus = WifiStationState.Status.INACTIVE,
+ concurrencySupported = false
+ )
+ )
+ }
+
+ @Test
+ fun startHotspotEnabled_whenStationActive_false_ifNoConcurrency() {
+ assertFalse(
+ HomeScreenUiLogic.isStartHotspotEnabled(
+ stationStatus = WifiStationState.Status.CONNECTING,
+ concurrencySupported = false
+ )
+ )
+ }
+
+ @Test
+ fun startHotspotEnabled_whenStationActive_true_ifConcurrencySupported() {
+ assertTrue(
+ HomeScreenUiLogic.isStartHotspotEnabled(
+ stationStatus = WifiStationState.Status.CONNECTING,
+ concurrencySupported = true
+ )
+ )
+ }
+
+ @Test
+ fun connectEnabled_whenHotspotNotStarted_true() {
+ assertTrue(HomeScreenUiLogic.isConnectActionEnabled(hotspotStarted = false, concurrencySupported = false))
+ }
+
+ @Test
+ fun connectEnabled_whenHotspotStarted_false_ifNoConcurrency() {
+ assertFalse(HomeScreenUiLogic.isConnectActionEnabled(hotspotStarted = true, concurrencySupported = false))
+ }
+
+ @Test
+ fun connectEnabled_whenHotspotStarted_true_ifConcurrencySupported() {
+ assertTrue(HomeScreenUiLogic.isConnectActionEnabled(hotspotStarted = true, concurrencySupported = true))
+ }
+
+ @Test
+ fun startStop_visibility_rules() {
+ assertTrue(HomeScreenUiLogic.shouldShowStartHotspotButton(false))
+ assertFalse(HomeScreenUiLogic.shouldShowStopHotspotButton(false))
+
+ assertTrue(HomeScreenUiLogic.shouldShowStopHotspotButton(true))
+ assertFalse(HomeScreenUiLogic.shouldShowStartHotspotButton(true))
+ }
+
+ @Test
+ fun qr_visibility_rules() {
+ assertFalse(HomeScreenUiLogic.shouldShowQrCode(connectUri = null, wifiConnectionEnabled = true))
+ assertFalse(HomeScreenUiLogic.shouldShowQrCode(connectUri = "mesh://link", wifiConnectionEnabled = false))
+ assertTrue(HomeScreenUiLogic.shouldShowQrCode(connectUri = "mesh://link", wifiConnectionEnabled = true))
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/LogScreenUILogicTest.kt b/app/src/test/java/com/greybox/projectmesh/views/LogScreenUILogicTest.kt
new file mode 100644
index 000000000..f0588e6d4
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/LogScreenUILogicTest.kt
@@ -0,0 +1,168 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/LogScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * JVM-testable logic extracted from LogScreen.kt behavior.
+ * We are NOT modifying LogScreen.kt right now.
+ *
+ * We model only the selection + formatting rules so we can later wire androidTest UI tests.
+ */
+private object LogScreenUiLogic {
+
+ data class LogLine(val lineId: Int, val time: Long, val line: String)
+
+ fun selectAll(logs: List): Set = logs.map { it.lineId }.toSet()
+
+ fun onLongPress(selectionMode: Boolean, lineId: Int): Pair> {
+ return if (!selectionMode) true to setOf(lineId) else selectionMode to emptySet()
+ }
+
+ fun toggleOnTap(selectionMode: Boolean, currentlySelected: Set, lineId: Int): Set {
+ if (!selectionMode) return currentlySelected
+ return if (currentlySelected.contains(lineId)) currentlySelected - lineId else currentlySelected + lineId
+ }
+
+ fun applyCheckbox(checked: Boolean, currentlySelected: Set, lineId: Int): Set {
+ return if (checked) currentlySelected + lineId else currentlySelected - lineId
+ }
+
+ fun copyPayload(
+ logs: List,
+ selectedLineIds: Set,
+ formatter: SimpleDateFormat
+ ): String {
+ return logs
+ .filter { selectedLineIds.contains(it.lineId) }
+ .joinToString("\n") { line ->
+ "[${formatter.format(Date(line.time))}] ${line.line}"
+ }
+ }
+
+ fun afterCopyReset(): Pair> = false to emptySet()
+ fun afterCancelReset(): Pair> = false to emptySet()
+}
+
+class LogScreenUiLogicTest {
+
+ private val formatter = SimpleDateFormat("HH:mm:ss.SS", Locale.US)
+
+ @Test
+ fun selectAll_returnsAllLineIds() {
+ val logs = listOf(
+ LogScreenUiLogic.LogLine(1, 1000L, "A"),
+ LogScreenUiLogic.LogLine(2, 2000L, "B"),
+ LogScreenUiLogic.LogLine(3, 3000L, "C")
+ )
+ assertEquals(setOf(1, 2, 3), LogScreenUiLogic.selectAll(logs))
+ }
+
+ @Test
+ fun onLongPress_whenNotInSelection_entersSelection_andSelectsThatLine() {
+ val (mode, selected) = LogScreenUiLogic.onLongPress(selectionMode = false, lineId = 42)
+ assertTrue(mode)
+ assertEquals(setOf(42), selected)
+ }
+
+ @Test
+ fun onLongPress_whenAlreadyInSelection_noStateChangeSuggested() {
+ val (mode, selected) = LogScreenUiLogic.onLongPress(selectionMode = true, lineId = 42)
+ assertTrue(mode)
+ assertEquals(emptySet(), selected) // we return emptySet to signal "no-op" in this helper design
+ }
+
+ @Test
+ fun toggleOnTap_whenNotInSelectionMode_noChange() {
+ val selected = LogScreenUiLogic.toggleOnTap(
+ selectionMode = false,
+ currentlySelected = setOf(1),
+ lineId = 2
+ )
+ assertEquals(setOf(1), selected)
+ }
+
+ @Test
+ fun toggleOnTap_whenSelected_removesIt() {
+ val selected = LogScreenUiLogic.toggleOnTap(
+ selectionMode = true,
+ currentlySelected = setOf(1, 2),
+ lineId = 2
+ )
+ assertEquals(setOf(1), selected)
+ }
+
+ @Test
+ fun toggleOnTap_whenNotSelected_addsIt() {
+ val selected = LogScreenUiLogic.toggleOnTap(
+ selectionMode = true,
+ currentlySelected = setOf(1),
+ lineId = 2
+ )
+ assertEquals(setOf(1, 2), selected)
+ }
+
+ @Test
+ fun applyCheckbox_checkedTrue_addsLineId() {
+ val selected = LogScreenUiLogic.applyCheckbox(
+ checked = true,
+ currentlySelected = setOf(1),
+ lineId = 2
+ )
+ assertEquals(setOf(1, 2), selected)
+ }
+
+ @Test
+ fun applyCheckbox_checkedFalse_removesLineId() {
+ val selected = LogScreenUiLogic.applyCheckbox(
+ checked = false,
+ currentlySelected = setOf(1, 2),
+ lineId = 2
+ )
+ assertEquals(setOf(1), selected)
+ }
+
+ @Test
+ fun copyPayload_formatsOnlySelectedLines_inOriginalOrder() {
+ val logs = listOf(
+ LogScreenUiLogic.LogLine(10, 0L, "first"),
+ LogScreenUiLogic.LogLine(20, 1234L, "second"),
+ LogScreenUiLogic.LogLine(30, 5678L, "third"),
+ )
+ val selectedIds = setOf(10, 30)
+
+ val payload = LogScreenUiLogic.copyPayload(
+ logs = logs,
+ selectedLineIds = selectedIds,
+ formatter = formatter
+ )
+
+ // We don't hardcode exact formatted time string (locale/timezone can vary);
+ // instead assert structure and inclusion.
+ assertTrue(payload.contains("] first"))
+ assertTrue(payload.contains("] third"))
+ assertFalse(payload.contains("] second"))
+ // ensures newline between two selected lines
+ assertTrue(payload.contains("\n"))
+ }
+
+ @Test
+ fun afterCopyReset_clearsSelectionMode_andSelectedIds() {
+ val (mode, selected) = LogScreenUiLogic.afterCopyReset()
+ assertFalse(mode)
+ assertTrue(selected.isEmpty())
+ }
+
+ @Test
+ fun afterCancelReset_clearsSelectionMode_andSelectedIds() {
+ val (mode, selected) = LogScreenUiLogic.afterCancelReset()
+ assertFalse(mode)
+ assertTrue(selected.isEmpty())
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/NetworkScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/NetworkScreenUiLogic.kt
new file mode 100644
index 000000000..2cb74ca4a
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/NetworkScreenUiLogic.kt
@@ -0,0 +1,225 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/NetworkScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.net.InetAddress
+
+/**
+ * Deep JVM tests for NetworkScreen.kt behavior WITHOUT touching NetworkScreen.kt.
+ *
+ * What NetworkScreen does on WifiListItem click (ipAddress):
+ * 1) addr = InetAddress.getByName(ipAddress)
+ * 2) appServer.requestRemoteUserInfo(addr)
+ * 3) appServer.pushUserInfoTo(addr)
+ * 4) onNodeClick(ipAddress) (navigation)
+ *
+ * Also it renders items from:
+ * uiState.allNodes.entries.toList()
+ * => preserves the Map iteration order.
+ */
+private object NetworkScreenUiLogic {
+
+ interface AppServerPort {
+ fun requestRemoteUserInfo(addr: InetAddress)
+ fun pushUserInfoTo(addr: InetAddress)
+ }
+
+ data class Effects(
+ val resolvedAddr: InetAddress?,
+ val navigationIp: String?,
+ val callTrace: List,
+ )
+
+ /**
+ * Mirrors the current onClick logic. Captures ordering.
+ *
+ * NOTE: In the real UI code, InetAddress.getByName may throw.
+ * We keep that behavior: resolver may throw -> no server calls, no navigation.
+ */
+ fun handleNodeClick(
+ ipAddress: String,
+ appServer: AppServerPort,
+ resolver: (String) -> InetAddress = { InetAddress.getByName(it) },
+ onNodeClick: (String) -> Unit = {},
+ ): Effects {
+ val trace = mutableListOf()
+ var addr: InetAddress? = null
+ var navIp: String? = null
+
+ addr = resolver(ipAddress)
+ trace.add("resolve:${addr.hostAddress}")
+
+ appServer.requestRemoteUserInfo(addr)
+ trace.add("requestRemoteUserInfo:${addr.hostAddress}")
+
+ appServer.pushUserInfoTo(addr)
+ trace.add("pushUserInfoTo:${addr.hostAddress}")
+
+ onNodeClick(ipAddress)
+ trace.add("navigate:$ipAddress")
+ navIp = ipAddress
+
+ return Effects(
+ resolvedAddr = addr,
+ navigationIp = navIp,
+ callTrace = trace.toList(),
+ )
+ }
+
+ /**
+ * Explicit helper for the rendering order implied by:
+ * uiState.allNodes.entries.toList()
+ */
+ fun renderOrderKeys(allNodes: Map): List {
+ return allNodes.entries.toList().map { it.key }
+ }
+}
+
+private class FakeAppServer : NetworkScreenUiLogic.AppServerPort {
+ val calls = mutableListOf()
+ override fun requestRemoteUserInfo(addr: InetAddress) {
+ calls.add("request:${addr.hostAddress}")
+ }
+
+ override fun pushUserInfoTo(addr: InetAddress) {
+ calls.add("push:${addr.hostAddress}")
+ }
+}
+
+class NetworkScreenUiLogicTest {
+
+ @Test
+ fun handleNodeClick_happyPath_callsResolve_thenRequest_thenPush_thenNavigate() {
+ val server = FakeAppServer()
+
+ // Avoid DNS/network: deterministic resolver that returns the same hostAddress as ipAddress
+ val resolver: (String) -> InetAddress = { ip ->
+ val bytes = ip.split(".").map { it.toInt().toByte() }.toByteArray()
+ InetAddress.getByAddress(ip, bytes)
+ }
+
+ var navigatedTo: String? = null
+ val onNodeClick: (String) -> Unit = { ip -> navigatedTo = ip }
+
+ val effects = NetworkScreenUiLogic.handleNodeClick(
+ ipAddress = "10.0.0.5",
+ appServer = server,
+ resolver = resolver,
+ onNodeClick = onNodeClick
+ )
+
+ assertNotNull(effects.resolvedAddr)
+ assertEquals("10.0.0.5", effects.resolvedAddr!!.hostAddress)
+ assertEquals("10.0.0.5", effects.navigationIp)
+ assertEquals("10.0.0.5", navigatedTo)
+
+ // Verify exact ordering (most important property for this screen)
+ assertEquals(
+ listOf(
+ "resolve:10.0.0.5",
+ "requestRemoteUserInfo:10.0.0.5",
+ "pushUserInfoTo:10.0.0.5",
+ "navigate:10.0.0.5",
+ ),
+ effects.callTrace
+ )
+
+ // Also verify server got calls in the correct order
+ assertEquals(
+ listOf("request:10.0.0.5", "push:10.0.0.5"),
+ server.calls
+ )
+ }
+
+ @Test
+ fun handleNodeClick_whenResolverThrows_noServerCalls_noNavigation() {
+ val server = FakeAppServer()
+
+ val resolver: (String) -> InetAddress = {
+ throw IllegalArgumentException("bad ip")
+ }
+
+ var navigatedTo: String? = null
+ val onNodeClick: (String) -> Unit = { ip -> navigatedTo = ip }
+
+ try {
+ NetworkScreenUiLogic.handleNodeClick(
+ ipAddress = "not_an_ip",
+ appServer = server,
+ resolver = resolver,
+ onNodeClick = onNodeClick
+ )
+ fail("Expected exception from resolver")
+ } catch (e: IllegalArgumentException) {
+ // expected
+ }
+
+ assertTrue(server.calls.isEmpty())
+ assertNull(navigatedTo)
+ }
+
+ @Test
+ fun handleNodeClick_callsAlwaysUseResolvedInetAddress_notOriginalString() {
+ val server = FakeAppServer()
+
+ // Resolver returns a DIFFERENT hostAddress than the passed-in string,
+ // proving we call server methods with the InetAddress, not string.
+ val resolver: (String) -> InetAddress = { _ ->
+ InetAddress.getByAddress("resolved", byteArrayOf(1, 2, 3, 4))
+ }
+
+ var navigatedTo: String? = null
+ val onNodeClick: (String) -> Unit = { ip -> navigatedTo = ip }
+
+ val effects = NetworkScreenUiLogic.handleNodeClick(
+ ipAddress = "10.9.9.9",
+ appServer = server,
+ resolver = resolver,
+ onNodeClick = onNodeClick
+ )
+
+ assertEquals("1.2.3.4", effects.resolvedAddr!!.hostAddress)
+ assertEquals(
+ listOf("request:1.2.3.4", "push:1.2.3.4"),
+ server.calls
+ )
+
+ // Navigation still uses the original ip string (matches NetworkScreen)
+ assertEquals("10.9.9.9", effects.navigationIp)
+ assertEquals("10.9.9.9", navigatedTo)
+ }
+
+ @Test
+ fun renderOrderKeys_preservesLinkedHashMapInsertionOrder() {
+ val map = linkedMapOf(
+ "192.168.0.2" to Any(),
+ "192.168.0.9" to Any(),
+ "192.168.0.3" to Any(),
+ )
+
+ val keys = NetworkScreenUiLogic.renderOrderKeys(map)
+ assertEquals(listOf("192.168.0.2", "192.168.0.9", "192.168.0.3"), keys)
+ }
+
+ @Test
+ fun renderOrderKeys_regularHashMap_orderIsNotGuaranteed_butFunctionStillReturnsSomeKeys() {
+ val map = hashMapOf(
+ "a" to 1,
+ "b" to 2,
+ "c" to 3,
+ )
+
+ val keys = NetworkScreenUiLogic.renderOrderKeys(map)
+
+ // We don't assert exact order for HashMap (implementation-dependent).
+ assertEquals(3, keys.size)
+ assertTrue(keys.containsAll(listOf("a", "b", "c")))
+ }
+
+ @Test
+ fun renderOrderKeys_emptyMap_returnsEmptyList() {
+ val keys = NetworkScreenUiLogic.renderOrderKeys(emptyMap())
+ assertTrue(keys.isEmpty())
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/OnboardingUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/OnboardingUiLogic.kt
new file mode 100644
index 000000000..6809380b3
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/OnboardingUiLogic.kt
@@ -0,0 +1,291 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/OnboardingUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for OnboardingScreen.kt behavior WITHOUT touching OnboardingScreen.kt.
+ *
+ * We model the exact "Next" onClick logic as currently written, including the duplicate
+ * handleFirstTimeSetup call at the end (likely a bug).
+ */
+private object OnboardingUiLogic {
+
+ interface VmPort {
+ fun onUsernameChange(value: String)
+ fun blankUsernameGenerator(cb: (String) -> Unit)
+ fun handleFirstTimeSetup(cb: () -> Unit)
+ }
+
+ data class Effects(
+ val handleFirstTimeSetupCalls: Int,
+ val blankUsernameGeneratorCalls: Int,
+ val onUsernameChangeCalls: Int,
+ val onCompleteCalls: Int,
+ val callTrace: List,
+ )
+
+ /**
+ * Mirrors the current UI onClick logic EXACTLY.
+ * Captures call counts + ordering as an Effects summary.
+ */
+ fun onNextClicked(currentUsername: String?, vm: VmPort, onComplete: () -> Unit): Effects {
+ val trace = mutableListOf()
+
+ var hfts = 0
+ var gen = 0
+ var nameChange = 0
+ var completed = 0
+
+ val countedComplete: () -> Unit = {
+ trace.add("onComplete")
+ completed++
+ onComplete()
+ }
+
+ val wrappedVm = object : VmPort {
+ override fun onUsernameChange(value: String) {
+ trace.add("onUsernameChange:$value")
+ nameChange++
+ vm.onUsernameChange(value)
+ }
+
+ override fun blankUsernameGenerator(cb: (String) -> Unit) {
+ trace.add("blankUsernameGenerator")
+ gen++
+ vm.blankUsernameGenerator(cb)
+ }
+
+ override fun handleFirstTimeSetup(cb: () -> Unit) {
+ trace.add("handleFirstTimeSetup")
+ hfts++
+ vm.handleFirstTimeSetup(cb)
+ }
+ }
+
+ if (currentUsername.isNullOrBlank()) {
+ wrappedVm.blankUsernameGenerator { generatedName ->
+ wrappedVm.onUsernameChange(generatedName)
+ wrappedVm.handleFirstTimeSetup { countedComplete() }
+ }
+ } else {
+ wrappedVm.handleFirstTimeSetup { countedComplete() }
+ }
+
+ // duplicate call present in UI code
+ wrappedVm.handleFirstTimeSetup { countedComplete() }
+
+ return Effects(
+ handleFirstTimeSetupCalls = hfts,
+ blankUsernameGeneratorCalls = gen,
+ onUsernameChangeCalls = nameChange,
+ onCompleteCalls = completed,
+ callTrace = trace.toList(),
+ )
+ }
+
+ fun isUsernameBlank(username: String?): Boolean = username.isNullOrBlank()
+}
+
+/**
+ * Fake VM that lets us control callback timing and validate ordering.
+ */
+private class FakeOnboardingVm(
+ private val generatedName: String = "mesh_user_123",
+ private val generatorCallsCallback: Boolean = true,
+ private val setupCallsCallback: Boolean = true
+) : OnboardingUiLogic.VmPort {
+
+ val received = mutableListOf()
+
+ override fun onUsernameChange(value: String) {
+ received.add("vm.onUsernameChange:$value")
+ }
+
+ override fun blankUsernameGenerator(cb: (String) -> Unit) {
+ received.add("vm.blankUsernameGenerator")
+ if (generatorCallsCallback) cb(generatedName)
+ }
+
+ override fun handleFirstTimeSetup(cb: () -> Unit) {
+ received.add("vm.handleFirstTimeSetup")
+ if (setupCallsCallback) cb()
+ }
+}
+
+class OnboardingUiLogicTest {
+
+ // ---------- username blankness ----------
+ @Test
+ fun isUsernameBlank_null_empty_whitespace_true_and_nonblank_false() {
+ assertTrue(OnboardingUiLogic.isUsernameBlank(null))
+ assertTrue(OnboardingUiLogic.isUsernameBlank(""))
+ assertTrue(OnboardingUiLogic.isUsernameBlank(" "))
+ assertTrue(OnboardingUiLogic.isUsernameBlank("\n\t "))
+
+ assertFalse(OnboardingUiLogic.isUsernameBlank("a"))
+ assertFalse(OnboardingUiLogic.isUsernameBlank(" jai "))
+ assertFalse(OnboardingUiLogic.isUsernameBlank("0"))
+ assertFalse(OnboardingUiLogic.isUsernameBlank("_"))
+ }
+
+ // ---------- path: username provided ----------
+ @Test
+ fun onNextClicked_nonBlankUsername_doesNotGenerate_callsSetupTwice_callsCompleteTwice() {
+ val vm = FakeOnboardingVm()
+ var completes = 0
+
+ val effects = OnboardingUiLogic.onNextClicked(
+ currentUsername = "jai",
+ vm = vm
+ ) { completes++ }
+
+ assertEquals(0, effects.blankUsernameGeneratorCalls)
+ assertEquals(0, effects.onUsernameChangeCalls)
+
+ // Due to duplicate call in UI:
+ assertEquals(2, effects.handleFirstTimeSetupCalls)
+ assertEquals(2, effects.onCompleteCalls)
+ assertEquals(2, completes)
+
+ // Order guarantee: setup -> complete -> setup -> complete
+ assertEquals(
+ listOf(
+ "handleFirstTimeSetup",
+ "onComplete",
+ "handleFirstTimeSetup",
+ "onComplete"
+ ),
+ effects.callTrace
+ )
+ }
+
+ // ---------- path: username blank ----------
+ @Test
+ fun onNextClicked_blankUsername_generates_thenChangesUsername_thenSetupInsideCallback_thenSetupAgain() {
+ val vm = FakeOnboardingVm(generatedName = "gen_name")
+ var completes = 0
+
+ val effects = OnboardingUiLogic.onNextClicked(
+ currentUsername = " ",
+ vm = vm
+ ) { completes++ }
+
+ assertEquals(1, effects.blankUsernameGeneratorCalls)
+ assertEquals(1, effects.onUsernameChangeCalls)
+
+ // setup happens once inside generator callback + once duplicated after if/else
+ assertEquals(2, effects.handleFirstTimeSetupCalls)
+ assertEquals(2, effects.onCompleteCalls)
+ assertEquals(2, completes)
+
+ // Exact order in current code:
+ // generator -> nameChange -> setup -> complete -> setup -> complete
+ assertEquals(
+ listOf(
+ "blankUsernameGenerator",
+ "onUsernameChange:gen_name",
+ "handleFirstTimeSetup",
+ "onComplete",
+ "handleFirstTimeSetup",
+ "onComplete"
+ ),
+ effects.callTrace
+ )
+ }
+
+ @Test
+ fun onNextClicked_nullUsername_treatedAsBlank() {
+ val vm = FakeOnboardingVm(generatedName = "gen")
+ val effects = OnboardingUiLogic.onNextClicked(
+ currentUsername = null,
+ vm = vm
+ ) { /* no-op */ }
+
+ assertEquals(1, effects.blankUsernameGeneratorCalls)
+ assertEquals(1, effects.onUsernameChangeCalls)
+ assertEquals(2, effects.handleFirstTimeSetupCalls)
+ assertEquals(2, effects.onCompleteCalls)
+ }
+
+ // ---------- edge: generator does NOT invoke callback ----------
+ @Test
+ fun onNextClicked_blankUsername_ifGeneratorNeverReturns_stillCallsSetupOnce_dueToDuplicateCall() {
+ val vm = FakeOnboardingVm(generatorCallsCallback = false)
+ val effects = OnboardingUiLogic.onNextClicked(
+ currentUsername = "",
+ vm = vm
+ ) { /* no-op */ }
+
+ // Generator called once, but no username change and no setup inside callback
+ assertEquals(1, effects.blankUsernameGeneratorCalls)
+ assertEquals(0, effects.onUsernameChangeCalls)
+
+ // Only the duplicated setup call executes
+ assertEquals(1, effects.handleFirstTimeSetupCalls)
+ assertEquals(1, effects.onCompleteCalls)
+
+ assertEquals(
+ listOf(
+ "blankUsernameGenerator",
+ "handleFirstTimeSetup",
+ "onComplete"
+ ),
+ effects.callTrace
+ )
+ }
+
+ // ---------- edge: handleFirstTimeSetup does NOT invoke callback ----------
+ @Test
+ fun onNextClicked_whenSetupDoesNotCallback_onCompleteNotCalled_evenThoughSetupCalledTwice() {
+ val vm = FakeOnboardingVm(setupCallsCallback = false)
+ val effects = OnboardingUiLogic.onNextClicked(
+ currentUsername = "jai",
+ vm = vm
+ ) { /* no-op */ }
+
+ assertEquals(2, effects.handleFirstTimeSetupCalls)
+ assertEquals(0, effects.onCompleteCalls)
+
+ assertEquals(
+ listOf(
+ "handleFirstTimeSetup",
+ "handleFirstTimeSetup"
+ ),
+ effects.callTrace
+ )
+ }
+
+ // ---------- integration-ish: verify VM receives expected calls ----------
+ @Test
+ fun vmReceivesExpectedCalls_inBlankFlow() {
+ val vm = FakeOnboardingVm(generatedName = "abc")
+ OnboardingUiLogic.onNextClicked(currentUsername = " ", vm = vm) {}
+
+ // VM-level calls (not the logic wrapper trace)
+ assertEquals(
+ listOf(
+ "vm.blankUsernameGenerator",
+ "vm.onUsernameChange:abc",
+ "vm.handleFirstTimeSetup",
+ "vm.handleFirstTimeSetup",
+ ),
+ vm.received
+ )
+ }
+
+ @Test
+ fun vmReceivesExpectedCalls_inNonBlankFlow() {
+ val vm = FakeOnboardingVm()
+ OnboardingUiLogic.onNextClicked(currentUsername = "jai", vm = vm) {}
+
+ assertEquals(
+ listOf(
+ "vm.handleFirstTimeSetup",
+ "vm.handleFirstTimeSetup",
+ ),
+ vm.received
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/PingScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/PingScreenUiLogic.kt
new file mode 100644
index 000000000..fafb29a14
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/PingScreenUiLogic.kt
@@ -0,0 +1,140 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/PingScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.net.InetAddress
+
+/**
+ * JVM-testable logic model for PingScreen.kt rendering strings.
+ * We are NOT touching PingScreen.kt right now.
+ *
+ * PingScreen mainly renders:
+ * - header text built from uiState.deviceName + uiState.virtualAddress.hostAddress
+ * - per-row text built from pingTimeSum, hopCount, lastHopAddr, messageId
+ *
+ * Since addressToDotNotation is an external extension, we model formatting via a passed formatter.
+ */
+private object PingScreenUiLogic {
+
+ data class MmcpOriginatorMessageLike(
+ val pingTimeSum: Long,
+ val messageId: Long
+ )
+
+ data class OriginatorMessageItemLike(
+ val originatorMessage: MmcpOriginatorMessageLike,
+ val hopCount: Int,
+ val lastHopAddr: InetAddress
+ )
+
+ fun headerText(deviceName: String?, virtualAddress: InetAddress): String {
+ return "Device name: $deviceName, IP address: ${virtualAddress.hostAddress}"
+ }
+
+ fun rowText(
+ item: OriginatorMessageItemLike,
+ lastHopFormatter: (InetAddress) -> String = { it.hostAddress }
+ ): String {
+ val msg = item.originatorMessage
+ return "Ping: ${msg.pingTimeSum}ms, hops: ${item.hopCount}, last hop: ${lastHopFormatter(item.lastHopAddr)}, id: ${msg.messageId}"
+ }
+
+ fun renderAllRows(
+ items: List,
+ lastHopFormatter: (InetAddress) -> String = { it.hostAddress }
+ ): List = items.map { rowText(it, lastHopFormatter) }
+}
+
+class PingScreenUiLogicTest {
+
+ private fun addr(bytes: ByteArray, host: String): InetAddress =
+ InetAddress.getByAddress(host, bytes)
+
+ @Test
+ fun headerText_includesDeviceName_andVirtualAddressHostAddress() {
+ val virtualAddr = addr(byteArrayOf(10, 0, 0, 7), "virtual")
+ val header = PingScreenUiLogic.headerText(deviceName = "Pixel", virtualAddress = virtualAddr)
+
+ assertEquals("Device name: Pixel, IP address: 10.0.0.7", header)
+ }
+
+ @Test
+ fun headerText_whenDeviceNameNull_rendersNullLiteral_matchesKotlinStringInterpolation() {
+ val virtualAddr = addr(byteArrayOf(192.toByte(), 168.toByte(), 1, 20), "virtual")
+ val header = PingScreenUiLogic.headerText(deviceName = null, virtualAddress = virtualAddr)
+
+ // Kotlin string interpolation of null -> "null"
+ assertEquals("Device name: null, IP address: 192.168.1.20", header)
+ }
+
+ @Test
+ fun rowText_formatsAllFields_andUsesFormatterForLastHop() {
+ val lastHop = addr(byteArrayOf(1, 2, 3, 4), "lasthop")
+ val item = PingScreenUiLogic.OriginatorMessageItemLike(
+ originatorMessage = PingScreenUiLogic.MmcpOriginatorMessageLike(
+ pingTimeSum = 123,
+ messageId = 99
+ ),
+ hopCount = 5,
+ lastHopAddr = lastHop
+ )
+
+ val row = PingScreenUiLogic.rowText(item) { inet -> "DOT(${inet.hostAddress})" }
+
+ assertEquals(
+ "Ping: 123ms, hops: 5, last hop: DOT(1.2.3.4), id: 99",
+ row
+ )
+ }
+
+ @Test
+ fun renderAllRows_preservesItemOrder() {
+ val a1 = addr(byteArrayOf(10, 0, 0, 1), "a1")
+ val a2 = addr(byteArrayOf(10, 0, 0, 2), "a2")
+
+ val items = listOf(
+ PingScreenUiLogic.OriginatorMessageItemLike(
+ originatorMessage = PingScreenUiLogic.MmcpOriginatorMessageLike(10, 1),
+ hopCount = 1,
+ lastHopAddr = a1
+ ),
+ PingScreenUiLogic.OriginatorMessageItemLike(
+ originatorMessage = PingScreenUiLogic.MmcpOriginatorMessageLike(20, 2),
+ hopCount = 2,
+ lastHopAddr = a2
+ )
+ )
+
+ val rows = PingScreenUiLogic.renderAllRows(items) { it.hostAddress }
+
+ assertEquals(
+ listOf(
+ "Ping: 10ms, hops: 1, last hop: 10.0.0.1, id: 1",
+ "Ping: 20ms, hops: 2, last hop: 10.0.0.2, id: 2"
+ ),
+ rows
+ )
+ }
+
+ @Test
+ fun rowText_handlesZeroAndLargeValues() {
+ val lastHop = addr(byteArrayOf(8, 8, 8, 8), "dns")
+ val item = PingScreenUiLogic.OriginatorMessageItemLike(
+ originatorMessage = PingScreenUiLogic.MmcpOriginatorMessageLike(
+ pingTimeSum = 0,
+ messageId = Long.MAX_VALUE
+ ),
+ hopCount = 0,
+ lastHopAddr = lastHop
+ )
+
+ val row = PingScreenUiLogic.rowText(item)
+
+ assertTrue(row.contains("Ping: 0ms"))
+ assertTrue(row.contains("hops: 0"))
+ assertTrue(row.contains("last hop: 8.8.8.8"))
+ assertTrue(row.contains("id: ${Long.MAX_VALUE}"))
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/ReceiveScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/ReceiveScreenUiLogic.kt
new file mode 100644
index 000000000..1f65fd3a5
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/ReceiveScreenUiLogic.kt
@@ -0,0 +1,233 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/ReceiveScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for ReceiveScreen.kt behavior WITHOUT touching ReceiveScreen.kt.
+ *
+ * Since the real file mixes Android framework (Context/Intent/MediaStore/SAF),
+ * our JVM tests focus on the deterministic "decision logic" that can be validated now:
+ *
+ * 1) Which transfers should be auto-accepted when autoFinishEnabled=true
+ * 2) Which trailing actions are visible given status
+ * 3) When openFile should attempt to open (guard conditions)
+ * 4) Download routing: content:// => SAF, else default path
+ * 5) Key stability rule: key = hashCode("${host}-${id}-${requestReceivedTime}")
+ *
+ * Later in androidTest, you can validate real clicks, intents, and storage APIs.
+ */
+private object ReceiveScreenUiLogic {
+
+ enum class Status { PENDING, COMPLETED, DECLINED, FAILED }
+
+ data class IncomingTransferInfoLike(
+ val fromHostAddress: String,
+ val id: String,
+ val requestReceivedTime: Long,
+ val name: String? = null,
+ val status: Status,
+ val hasFile: Boolean,
+ )
+
+ enum class TrailingUi {
+ NONE,
+ PENDING_ACCEPT_DECLINE,
+ COMPLETED_DELETE_DOWNLOAD,
+ DECLINED_OR_FAILED_DELETE_ONLY
+ }
+
+ /**
+ * Mirrors:
+ * if (autoFinishEnabled) incomingTransfers.filter { status==PENDING }.forEach(onAccept)
+ */
+ fun transfersToAutoAccept(
+ autoFinishEnabled: Boolean,
+ transfers: List
+ ): List {
+ if (!autoFinishEnabled) return emptyList()
+ return transfers.filter { it.status == Status.PENDING }
+ }
+
+ /**
+ * Mirrors conditional rendering in ListItem supporting/trailing content.
+ */
+ fun trailingUiFor(status: Status): TrailingUi = when (status) {
+ Status.PENDING -> TrailingUi.PENDING_ACCEPT_DECLINE
+ Status.COMPLETED -> TrailingUi.COMPLETED_DELETE_DOWNLOAD
+ Status.DECLINED, Status.FAILED -> TrailingUi.DECLINED_OR_FAILED_DELETE_ONLY
+ }
+
+ /**
+ * Mirrors openFile guard:
+ * if (file != null && status == COMPLETED) -> attempt open
+ */
+ fun shouldAttemptOpenFile(status: Status, hasFile: Boolean): Boolean {
+ return hasFile && status == Status.COMPLETED
+ }
+
+ enum class DownloadRoute { SAF_CONTENT_URI, DEFAULT_PATH }
+
+ /**
+ * Mirrors:
+ * if (uriOrPath.startsWith("content://")) saveFileToContentUri else saveFileToDefaultPath
+ */
+ fun downloadRoute(uriOrPath: String): DownloadRoute {
+ return if (uriOrPath.startsWith("content://")) DownloadRoute.SAF_CONTENT_URI
+ else DownloadRoute.DEFAULT_PATH
+ }
+
+ /**
+ * Mirrors key expression:
+ * key = {"${it.fromHost.hostAddress}-${it.id}-${it.requestReceivedTime}".hashCode()}
+ */
+ fun listKeyHash(fromHostAddress: String, id: String, requestReceivedTime: Long): Int {
+ return "$fromHostAddress-$id-$requestReceivedTime".hashCode()
+ }
+}
+
+class ReceiveScreenUiLogicTest {
+
+ // ---------- Auto-accept logic ----------
+ @Test
+ fun transfersToAutoAccept_whenDisabled_returnsEmpty() {
+ val transfers = listOf(
+ ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "a", 1L, status = ReceiveScreenUiLogic.Status.PENDING, hasFile = false),
+ ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "b", 2L, status = ReceiveScreenUiLogic.Status.COMPLETED, hasFile = true)
+ )
+
+ val result = ReceiveScreenUiLogic.transfersToAutoAccept(false, transfers)
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun transfersToAutoAccept_whenEnabled_returnsOnlyPending_preservesOrder() {
+ val t1 = ReceiveScreenUiLogic.IncomingTransferInfoLike("10.0.0.1", "1", 100L, status = ReceiveScreenUiLogic.Status.PENDING, hasFile = false)
+ val t2 = ReceiveScreenUiLogic.IncomingTransferInfoLike("10.0.0.2", "2", 200L, status = ReceiveScreenUiLogic.Status.COMPLETED, hasFile = true)
+ val t3 = ReceiveScreenUiLogic.IncomingTransferInfoLike("10.0.0.3", "3", 300L, status = ReceiveScreenUiLogic.Status.PENDING, hasFile = false)
+
+ val result = ReceiveScreenUiLogic.transfersToAutoAccept(true, listOf(t1, t2, t3))
+
+ assertEquals(listOf(t1, t3), result)
+ }
+
+ @Test
+ fun transfersToAutoAccept_whenEnabled_andNoPending_returnsEmpty() {
+ val transfers = listOf(
+ ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "a", 1L, status = ReceiveScreenUiLogic.Status.COMPLETED, hasFile = true),
+ ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "b", 2L, status = ReceiveScreenUiLogic.Status.DECLINED, hasFile = false)
+ )
+
+ val result = ReceiveScreenUiLogic.transfersToAutoAccept(true, transfers)
+ assertTrue(result.isEmpty())
+ }
+
+ // ---------- Trailing UI decisions ----------
+ @Test
+ fun trailingUiFor_pending_showsAcceptDecline() {
+ assertEquals(
+ ReceiveScreenUiLogic.TrailingUi.PENDING_ACCEPT_DECLINE,
+ ReceiveScreenUiLogic.trailingUiFor(ReceiveScreenUiLogic.Status.PENDING)
+ )
+ }
+
+ @Test
+ fun trailingUiFor_completed_showsDeleteDownload() {
+ assertEquals(
+ ReceiveScreenUiLogic.TrailingUi.COMPLETED_DELETE_DOWNLOAD,
+ ReceiveScreenUiLogic.trailingUiFor(ReceiveScreenUiLogic.Status.COMPLETED)
+ )
+ }
+
+ @Test
+ fun trailingUiFor_declinedOrFailed_showsDeleteOnly() {
+ assertEquals(
+ ReceiveScreenUiLogic.TrailingUi.DECLINED_OR_FAILED_DELETE_ONLY,
+ ReceiveScreenUiLogic.trailingUiFor(ReceiveScreenUiLogic.Status.DECLINED)
+ )
+ assertEquals(
+ ReceiveScreenUiLogic.TrailingUi.DECLINED_OR_FAILED_DELETE_ONLY,
+ ReceiveScreenUiLogic.trailingUiFor(ReceiveScreenUiLogic.Status.FAILED)
+ )
+ }
+
+ // ---------- openFile guard ----------
+ @Test
+ fun shouldAttemptOpenFile_onlyWhenCompleted_andHasFile() {
+ assertTrue(ReceiveScreenUiLogic.shouldAttemptOpenFile(ReceiveScreenUiLogic.Status.COMPLETED, hasFile = true))
+
+ assertFalse(ReceiveScreenUiLogic.shouldAttemptOpenFile(ReceiveScreenUiLogic.Status.COMPLETED, hasFile = false))
+ assertFalse(ReceiveScreenUiLogic.shouldAttemptOpenFile(ReceiveScreenUiLogic.Status.PENDING, hasFile = true))
+ assertFalse(ReceiveScreenUiLogic.shouldAttemptOpenFile(ReceiveScreenUiLogic.Status.DECLINED, hasFile = true))
+ assertFalse(ReceiveScreenUiLogic.shouldAttemptOpenFile(ReceiveScreenUiLogic.Status.FAILED, hasFile = true))
+ }
+
+ // ---------- Download routing ----------
+ @Test
+ fun downloadRoute_contentUri_goesToSAF() {
+ assertEquals(
+ ReceiveScreenUiLogic.DownloadRoute.SAF_CONTENT_URI,
+ ReceiveScreenUiLogic.downloadRoute("content://com.android.externalstorage.documents/tree/primary%3ADownload")
+ )
+ }
+
+ @Test
+ fun downloadRoute_filePath_goesToDefaultPath() {
+ assertEquals(
+ ReceiveScreenUiLogic.DownloadRoute.DEFAULT_PATH,
+ ReceiveScreenUiLogic.downloadRoute("/storage/emulated/0/Download/Project Mesh")
+ )
+ }
+
+ @Test
+ fun downloadRoute_edgeCases_nonContentSchemes_goToDefaultPath() {
+ assertEquals(ReceiveScreenUiLogic.DownloadRoute.DEFAULT_PATH, ReceiveScreenUiLogic.downloadRoute("file:///sdcard/Download"))
+ assertEquals(ReceiveScreenUiLogic.DownloadRoute.DEFAULT_PATH, ReceiveScreenUiLogic.downloadRoute("http://example.com/x"))
+ assertEquals(ReceiveScreenUiLogic.DownloadRoute.DEFAULT_PATH, ReceiveScreenUiLogic.downloadRoute(""))
+ assertEquals(ReceiveScreenUiLogic.DownloadRoute.DEFAULT_PATH, ReceiveScreenUiLogic.downloadRoute("CONTENT://not-matching-case")) // case-sensitive
+ }
+
+ // ---------- Key hash stability ----------
+ @Test
+ fun listKeyHash_sameInputs_sameHash() {
+ val a = ReceiveScreenUiLogic.listKeyHash("10.0.0.1", "abc", 123L)
+ val b = ReceiveScreenUiLogic.listKeyHash("10.0.0.1", "abc", 123L)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun listKeyHash_changeAnyField_changesHash_mostOfTime() {
+ val base = ReceiveScreenUiLogic.listKeyHash("10.0.0.1", "abc", 123L)
+
+ val diffHost = ReceiveScreenUiLogic.listKeyHash("10.0.0.2", "abc", 123L)
+ val diffId = ReceiveScreenUiLogic.listKeyHash("10.0.0.1", "abd", 123L)
+ val diffTime = ReceiveScreenUiLogic.listKeyHash("10.0.0.1", "abc", 124L)
+
+ // Hash collisions are theoretically possible, but extremely unlikely for these small strings.
+ assertNotEquals(base, diffHost)
+ assertNotEquals(base, diffId)
+ assertNotEquals(base, diffTime)
+ }
+
+ // ---------- Combined scenario test ----------
+ @Test
+ fun scenario_pendingTransfers_autoAcceptTargetsMatch_andTrailingUiMatches() {
+ val tPending1 = ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "1", 1L, status = ReceiveScreenUiLogic.Status.PENDING, hasFile = false)
+ val tPending2 = ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "2", 2L, status = ReceiveScreenUiLogic.Status.PENDING, hasFile = false)
+ val tDone = ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "3", 3L, status = ReceiveScreenUiLogic.Status.COMPLETED, hasFile = true)
+ val tFail = ReceiveScreenUiLogic.IncomingTransferInfoLike("1.1.1.1", "4", 4L, status = ReceiveScreenUiLogic.Status.FAILED, hasFile = true)
+
+ val transfers = listOf(tPending1, tDone, tPending2, tFail)
+
+ val auto = ReceiveScreenUiLogic.transfersToAutoAccept(true, transfers)
+ assertEquals(listOf(tPending1, tPending2), auto)
+
+ assertEquals(ReceiveScreenUiLogic.TrailingUi.PENDING_ACCEPT_DECLINE, ReceiveScreenUiLogic.trailingUiFor(tPending1.status))
+ assertEquals(ReceiveScreenUiLogic.TrailingUi.COMPLETED_DELETE_DOWNLOAD, ReceiveScreenUiLogic.trailingUiFor(tDone.status))
+ assertEquals(ReceiveScreenUiLogic.TrailingUi.DECLINED_OR_FAILED_DELETE_ONLY, ReceiveScreenUiLogic.trailingUiFor(tFail.status))
+
+ assertTrue(ReceiveScreenUiLogic.shouldAttemptOpenFile(tDone.status, tDone.hasFile))
+ assertFalse(ReceiveScreenUiLogic.shouldAttemptOpenFile(tPending1.status, tPending1.hasFile))
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/RequestPermissionsUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/RequestPermissionsUiLogic.kt
new file mode 100644
index 000000000..dbd2008ba
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/RequestPermissionsUiLogic.kt
@@ -0,0 +1,418 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/RequestPermissionsUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for RequestPermissionsScreen.kt WITHOUT touching that file.
+ *
+ * The Composable uses Android framework + rememberLauncherForActivityResult, which are instrumentation-only.
+ * For JVM tests, we model the deterministic step-machine decisions:
+ * - Given SDK level + granted permissions + battery optimization state,
+ * what action should happen at each step?
+ *
+ * Later in androidTest, you'll verify actual permission launchers and dialogs.
+ */
+private object RequestPermissionsUiLogic {
+
+ // Mirror steps from the Composable
+ const val STEP_NEARBY_WIFI = 0
+ const val STEP_LOCATION = 1
+ const val STEP_NOTIFICATIONS = 2
+ const val STEP_STORAGE = 3
+ const val STEP_CAMERA = 4
+ const val STEP_BATTERY = 5
+ const val STEP_DONE = 6
+
+ /**
+ * We model Android version boundaries used in the file:
+ * - M = 23 (permission runtime checks)
+ * - TIRAMISU = 33 (POST_NOTIFICATIONS + READ_MEDIA_*)
+ */
+ const val SDK_M = 23
+ const val SDK_TIRAMISU = 33
+
+ // Permission names (strings only; no Android dependency in unit tests)
+ const val PERM_NEARBY_WIFI = "android.permission.NEARBY_WIFI_DEVICES"
+ const val PERM_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION"
+ const val PERM_POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
+ const val PERM_READ_MEDIA_IMAGES = "android.permission.READ_MEDIA_IMAGES"
+ const val PERM_READ_MEDIA_VIDEO = "android.permission.READ_MEDIA_VIDEO"
+ const val PERM_READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE"
+ const val PERM_CAMERA = "android.permission.CAMERA"
+
+ sealed class Action {
+ data class LaunchSingle(val permission: String, val nextStepOnResult: Int) : Action()
+ data class LaunchMultiple(val permissions: Array, val nextStepOnResult: Int) : Action()
+ data class AdvanceTo(val nextStep: Int) : Action()
+ object PromptBatteryOptimization : Action()
+ object NoOp : Action() // step==DONE
+ }
+
+ /**
+ * Computes the next action that LaunchedEffect would take at a given step.
+ *
+ * Inputs:
+ * - sdkInt: device SDK
+ * - granted: set of granted permissions
+ * - batteryOptimizationDisabled: whether optimization is already disabled (ignoring optimizations)
+ */
+ fun nextAction(
+ currentStep: Int,
+ sdkInt: Int,
+ granted: Set,
+ batteryOptimizationDisabled: Boolean
+ ): Action {
+
+ if (currentStep == STEP_DONE) return Action.NoOp
+
+ fun has(p: String) = granted.contains(p)
+ fun hasAny(ps: Array) = ps.any { has(it) }
+
+ return when (currentStep) {
+ STEP_NEARBY_WIFI -> {
+ if (sdkInt >= SDK_M && !has(PERM_NEARBY_WIFI)) {
+ Action.LaunchSingle(PERM_NEARBY_WIFI, nextStepOnResult = STEP_LOCATION)
+ } else {
+ Action.AdvanceTo(STEP_LOCATION)
+ }
+ }
+
+ STEP_LOCATION -> {
+ if (sdkInt >= SDK_M && !has(PERM_FINE_LOCATION)) {
+ Action.LaunchSingle(PERM_FINE_LOCATION, nextStepOnResult = STEP_NOTIFICATIONS)
+ } else {
+ Action.AdvanceTo(STEP_NOTIFICATIONS)
+ }
+ }
+
+ STEP_NOTIFICATIONS -> {
+ if (sdkInt >= SDK_TIRAMISU && !has(PERM_POST_NOTIFICATIONS)) {
+ Action.LaunchSingle(PERM_POST_NOTIFICATIONS, nextStepOnResult = STEP_STORAGE)
+ } else {
+ Action.AdvanceTo(STEP_STORAGE)
+ }
+ }
+
+ STEP_STORAGE -> {
+ val storagePerms = if (sdkInt >= SDK_TIRAMISU) {
+ arrayOf(PERM_READ_MEDIA_IMAGES, PERM_READ_MEDIA_VIDEO)
+ } else {
+ arrayOf(PERM_READ_EXTERNAL_STORAGE)
+ }
+
+ if (!hasAny(storagePerms)) {
+ Action.LaunchMultiple(storagePerms, nextStepOnResult = STEP_CAMERA)
+ } else {
+ Action.AdvanceTo(STEP_CAMERA)
+ }
+ }
+
+ STEP_CAMERA -> {
+ if (!has(PERM_CAMERA)) {
+ Action.LaunchSingle(PERM_CAMERA, nextStepOnResult = STEP_BATTERY)
+ } else {
+ Action.AdvanceTo(STEP_BATTERY)
+ }
+ }
+
+ STEP_BATTERY -> {
+ if (!batteryOptimizationDisabled) Action.PromptBatteryOptimization
+ else Action.NoOp // real code does nothing else here
+ }
+
+ else -> throw IllegalArgumentException("Unknown step: $currentStep")
+ }
+ }
+
+ fun initialStep(skipPermissions: Boolean): Int = if (skipPermissions) STEP_DONE else STEP_NEARBY_WIFI
+}
+
+class RequestPermissionsUiLogicTest {
+
+ // ---------- initial step ----------
+ @Test
+ fun initialStep_whenSkip_true_isDone() {
+ assertEquals(RequestPermissionsUiLogic.STEP_DONE, RequestPermissionsUiLogic.initialStep(true))
+ }
+
+ @Test
+ fun initialStep_whenSkip_false_isNearbyWifi() {
+ assertEquals(RequestPermissionsUiLogic.STEP_NEARBY_WIFI, RequestPermissionsUiLogic.initialStep(false))
+ }
+
+ // ---------- step 0: nearby wifi ----------
+ @Test
+ fun step0_sdkBelowM_advancesWithoutRequest() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NEARBY_WIFI,
+ sdkInt = 22,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_LOCATION), action)
+ }
+
+ @Test
+ fun step0_sdkAtLeastM_andNotGranted_requestsNearbyWifi() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NEARBY_WIFI,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(
+ RequestPermissionsUiLogic.Action.LaunchSingle(
+ RequestPermissionsUiLogic.PERM_NEARBY_WIFI,
+ nextStepOnResult = RequestPermissionsUiLogic.STEP_LOCATION
+ ),
+ action
+ )
+ }
+
+ @Test
+ fun step0_whenAlreadyGranted_advances() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NEARBY_WIFI,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_NEARBY_WIFI),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_LOCATION), action)
+ }
+
+ // ---------- step 1: location ----------
+ @Test
+ fun step1_sdkAtLeastM_andNotGranted_requestsLocation() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_LOCATION,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(
+ RequestPermissionsUiLogic.Action.LaunchSingle(
+ RequestPermissionsUiLogic.PERM_FINE_LOCATION,
+ nextStepOnResult = RequestPermissionsUiLogic.STEP_NOTIFICATIONS
+ ),
+ action
+ )
+ }
+
+ @Test
+ fun step1_whenGranted_advances() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_LOCATION,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_FINE_LOCATION),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_NOTIFICATIONS), action)
+ }
+
+ // ---------- step 2: notifications ----------
+ @Test
+ fun step2_sdkBelowTiramisu_advancesWithoutRequest() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NOTIFICATIONS,
+ sdkInt = 32,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_STORAGE), action)
+ }
+
+ @Test
+ fun step2_sdkAtLeastTiramisu_andNotGranted_requestsNotifications() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NOTIFICATIONS,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(
+ RequestPermissionsUiLogic.Action.LaunchSingle(
+ RequestPermissionsUiLogic.PERM_POST_NOTIFICATIONS,
+ nextStepOnResult = RequestPermissionsUiLogic.STEP_STORAGE
+ ),
+ action
+ )
+ }
+
+ @Test
+ fun step2_sdkAtLeastTiramisu_andGranted_advances() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_NOTIFICATIONS,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_POST_NOTIFICATIONS),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_STORAGE), action)
+ }
+
+ // ---------- step 3: storage ----------
+ @Test
+ fun step3_sdkBelowTiramisu_requestsReadExternalStorage_ifNoneGranted() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_STORAGE,
+ sdkInt = 32,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+
+ val expected = RequestPermissionsUiLogic.Action.LaunchMultiple(
+ arrayOf(RequestPermissionsUiLogic.PERM_READ_EXTERNAL_STORAGE),
+ nextStepOnResult = RequestPermissionsUiLogic.STEP_CAMERA
+ )
+
+ // compare arrays safely
+ assertTrue(action is RequestPermissionsUiLogic.Action.LaunchMultiple)
+ val a = action as RequestPermissionsUiLogic.Action.LaunchMultiple
+ assertEquals(expected.nextStepOnResult, a.nextStepOnResult)
+ assertArrayEquals(expected.permissions, a.permissions)
+ }
+
+ @Test
+ fun step3_sdkBelowTiramisu_advances_ifReadExternalStorageGranted() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_STORAGE,
+ sdkInt = 32,
+ granted = setOf(RequestPermissionsUiLogic.PERM_READ_EXTERNAL_STORAGE),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_CAMERA), action)
+ }
+
+ @Test
+ fun step3_sdkAtLeastTiramisu_requestsMediaPerms_ifNeitherGranted() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_STORAGE,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+
+ assertTrue(action is RequestPermissionsUiLogic.Action.LaunchMultiple)
+ val a = action as RequestPermissionsUiLogic.Action.LaunchMultiple
+ assertEquals(RequestPermissionsUiLogic.STEP_CAMERA, a.nextStepOnResult)
+ assertArrayEquals(
+ arrayOf(RequestPermissionsUiLogic.PERM_READ_MEDIA_IMAGES, RequestPermissionsUiLogic.PERM_READ_MEDIA_VIDEO),
+ a.permissions
+ )
+ }
+
+ @Test
+ fun step3_sdkAtLeastTiramisu_advances_ifAnyMediaPermGranted() {
+ val action1 = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_STORAGE,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_READ_MEDIA_IMAGES),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_CAMERA), action1)
+
+ val action2 = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_STORAGE,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_READ_MEDIA_VIDEO),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_CAMERA), action2)
+ }
+
+ // ---------- step 4: camera ----------
+ @Test
+ fun step4_whenNotGranted_requestsCamera() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_CAMERA,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(
+ RequestPermissionsUiLogic.Action.LaunchSingle(
+ RequestPermissionsUiLogic.PERM_CAMERA,
+ nextStepOnResult = RequestPermissionsUiLogic.STEP_BATTERY
+ ),
+ action
+ )
+ }
+
+ @Test
+ fun step4_whenGranted_advancesToBattery() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_CAMERA,
+ sdkInt = 33,
+ granted = setOf(RequestPermissionsUiLogic.PERM_CAMERA),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_BATTERY), action)
+ }
+
+ // ---------- step 5: battery ----------
+ @Test
+ fun step5_whenBatteryOptimizationNotDisabled_prompts() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_BATTERY,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.PromptBatteryOptimization, action)
+ }
+
+ @Test
+ fun step5_whenBatteryOptimizationAlreadyDisabled_noop() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_BATTERY,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = true
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.NoOp, action)
+ }
+
+ // ---------- done ----------
+ @Test
+ fun step6_isNoOp() {
+ val action = RequestPermissionsUiLogic.nextAction(
+ currentStep = RequestPermissionsUiLogic.STEP_DONE,
+ sdkInt = 33,
+ granted = emptySet(),
+ batteryOptimizationDisabled = false
+ )
+ assertEquals(RequestPermissionsUiLogic.Action.NoOp, action)
+ }
+
+ // ---------- scenario: full flow sanity ----------
+ @Test
+ fun scenario_fullFlow_allGranted_skipsToBattery_thenNoPromptIfDisabled() {
+ // If all permissions are granted, LaunchedEffect would advance quickly
+ val granted = setOf(
+ RequestPermissionsUiLogic.PERM_NEARBY_WIFI,
+ RequestPermissionsUiLogic.PERM_FINE_LOCATION,
+ RequestPermissionsUiLogic.PERM_POST_NOTIFICATIONS,
+ RequestPermissionsUiLogic.PERM_READ_MEDIA_IMAGES,
+ RequestPermissionsUiLogic.PERM_CAMERA
+ )
+
+ val a0 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_NEARBY_WIFI, 33, granted, false)
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_LOCATION), a0)
+
+ val a1 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_LOCATION, 33, granted, false)
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_NOTIFICATIONS), a1)
+
+ val a2 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_NOTIFICATIONS, 33, granted, false)
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_STORAGE), a2)
+
+ val a3 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_STORAGE, 33, granted, false)
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_CAMERA), a3)
+
+ val a4 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_CAMERA, 33, granted, false)
+ assertEquals(RequestPermissionsUiLogic.Action.AdvanceTo(RequestPermissionsUiLogic.STEP_BATTERY), a4)
+
+ val a5 = RequestPermissionsUiLogic.nextAction(RequestPermissionsUiLogic.STEP_BATTERY, 33, granted, true)
+ assertEquals(RequestPermissionsUiLogic.Action.NoOp, a5)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/SelectDestNodeScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/SelectDestNodeScreenUiLogic.kt
new file mode 100644
index 000000000..9ae44c3b5
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/SelectDestNodeScreenUiLogic.kt
@@ -0,0 +1,107 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/SelectDestNodeScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for SelectDestNodeScreen.kt WITHOUT touching that file.
+ *
+ * What we can unit-test now (pure logic):
+ * - UI branch decision: show progress vs show list
+ * - Render order of nodes (Map iteration order via entries.toList())
+ * - Click behavior: passes entry.key to onClickReceiver
+ *
+ * Later in androidTest, verify actual Compose UI interactions.
+ */
+private object SelectDestNodeScreenUiLogic {
+
+ sealed class UiMode {
+ data class InProgress(val deviceName: String) : UiMode()
+ object ListMode : UiMode()
+ }
+
+ fun uiMode(contactingInProgressDevice: String?): UiMode {
+ return if (contactingInProgressDevice != null) UiMode.InProgress(contactingInProgressDevice)
+ else UiMode.ListMode
+ }
+
+ fun renderOrderKeys(allNodes: Map): List {
+ // Mirrors: uiState.allNodes.entries.toList()
+ return allNodes.entries.toList().map { it.key }
+ }
+
+ fun handleNodeClick(key: Int, onClickReceiver: (Int) -> Unit): Int {
+ onClickReceiver(key)
+ return key
+ }
+
+ fun progressText(deviceName: String): String {
+ return "Contacting $deviceName\nThis might take a few seconds."
+ }
+}
+
+class SelectDestNodeScreenUiLogicTest {
+
+ // ---------- mode selection ----------
+ @Test
+ fun uiMode_whenContactingNotNull_isInProgress() {
+ val mode = SelectDestNodeScreenUiLogic.uiMode("Pixel-7")
+ assertTrue(mode is SelectDestNodeScreenUiLogic.UiMode.InProgress)
+ val m = mode as SelectDestNodeScreenUiLogic.UiMode.InProgress
+ assertEquals("Pixel-7", m.deviceName)
+ }
+
+ @Test
+ fun uiMode_whenContactingNull_isListMode() {
+ val mode = SelectDestNodeScreenUiLogic.uiMode(null)
+ assertTrue(mode is SelectDestNodeScreenUiLogic.UiMode.ListMode)
+ }
+
+ // ---------- progress text ----------
+ @Test
+ fun progressText_matchesUiString() {
+ val text = SelectDestNodeScreenUiLogic.progressText("DeviceA")
+ assertEquals("Contacting DeviceA\nThis might take a few seconds.", text)
+ }
+
+ // ---------- render order ----------
+ @Test
+ fun renderOrderKeys_preservesLinkedHashMapInsertionOrder() {
+ val map = linkedMapOf(
+ 10 to Any(),
+ 20 to Any(),
+ 30 to Any()
+ )
+ val keys = SelectDestNodeScreenUiLogic.renderOrderKeys(map)
+ assertEquals(listOf(10, 20, 30), keys)
+ }
+
+ @Test
+ fun renderOrderKeys_hashMap_orderNotGuaranteed_butContainsAllKeys() {
+ val map = hashMapOf(
+ 1 to Any(),
+ 2 to Any(),
+ 3 to Any()
+ )
+ val keys = SelectDestNodeScreenUiLogic.renderOrderKeys(map)
+ assertEquals(3, keys.size)
+ assertTrue(keys.containsAll(listOf(1, 2, 3)))
+ }
+
+ @Test
+ fun renderOrderKeys_emptyMap_returnsEmpty() {
+ val keys = SelectDestNodeScreenUiLogic.renderOrderKeys(emptyMap())
+ assertTrue(keys.isEmpty())
+ }
+
+ // ---------- click behavior ----------
+ @Test
+ fun handleNodeClick_callsReceiver_withExactKey_andReturnsKey() {
+ var received: Int? = null
+ val returned = SelectDestNodeScreenUiLogic.handleNodeClick(99) { k -> received = k }
+
+ assertEquals(99, returned)
+ assertEquals(99, received)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/SendScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/SendScreenUiLogic.kt
new file mode 100644
index 000000000..b3060b869
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/SendScreenUiLogic.kt
@@ -0,0 +1,187 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/SendScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for SendScreen.kt WITHOUT touching that file.
+ *
+ * What we can test in JVM:
+ * 1) File picker callback behavior: only call onFileChosen when uris not empty
+ * 2) Swipe confirm logic: only delete on EndToStart; StartToEnd does nothing
+ * 3) Formatting helpers: autoConvertByte, autoConvertMS (these are pure functions in this file)
+ *
+ * What we DON'T test here (androidTest later):
+ * - Compose animations & SwipeToDismissBox state integration
+ * - ActivityResult launcher plumbing
+ * - GlobalApp repo calls + runBlocking UI impacts
+ */
+private object SendScreenUiLogic {
+
+ enum class SwipeValue { EndToStart, StartToEnd, Settled }
+
+ data class FilePickerEffects(
+ val shouldCallOnFileChosen: Boolean,
+ val passedUris: List
+ )
+
+ fun onFilesPicked(uris: List): FilePickerEffects {
+ return FilePickerEffects(
+ shouldCallOnFileChosen = uris.isNotEmpty(),
+ passedUris = if (uris.isNotEmpty()) uris else emptyList()
+ )
+ }
+
+ data class SwipeEffects(
+ val shouldDelete: Boolean,
+ val confirmReturnValue: Boolean,
+ val shouldFadeOut: Boolean,
+ val delayMsBeforeDelete: Long?
+ )
+
+ /**
+ * Mirrors confirmValueChange in the code:
+ * if dismissValue == EndToStart:
+ * launch { isVisible=false; delay(300); onDelete(transfer) }
+ * return true
+ * else false
+ */
+ fun onSwipeConfirm(dismissValue: SwipeValue): SwipeEffects {
+ return if (dismissValue == SwipeValue.EndToStart) {
+ SwipeEffects(
+ shouldDelete = true,
+ confirmReturnValue = true,
+ shouldFadeOut = true,
+ delayMsBeforeDelete = 300L
+ )
+ } else {
+ SwipeEffects(
+ shouldDelete = false,
+ confirmReturnValue = false,
+ shouldFadeOut = false,
+ delayMsBeforeDelete = null
+ )
+ }
+ }
+
+ // ---- copy of helpers (so tests don’t depend on Android/Compose) ----
+ fun autoConvertByte(byteSize: Int): String {
+ val kb = Math.round(byteSize / 1024.0 * 100) / 100.0
+ val mb = Math.round((byteSize / (1024.0 * 1024.0) * 100) / 100.0)
+ if (byteSize == 0) {
+ return "0B"
+ } else if (mb < 1) {
+ return "${kb}KB"
+ }
+ return "${mb}MB"
+ }
+
+ fun autoConvertMS(ms: Int): String {
+ val second = Math.round(ms / 1000.0 * 100) / 100.0
+ val minute = Math.round((second / 60.0) * 100) / 100.0
+ return if (second >= 1 && minute < 1) {
+ "${second}s"
+ } else if (minute >= 1) {
+ "${minute}m"
+ } else {
+ "${ms}ms"
+ }
+ }
+}
+
+class SendScreenUiLogicTest {
+
+ // ---------- file picker logic ----------
+ @Test
+ fun onFilesPicked_whenEmpty_doesNotCallVm() {
+ val effects = SendScreenUiLogic.onFilesPicked(emptyList())
+ assertFalse(effects.shouldCallOnFileChosen)
+ assertTrue(effects.passedUris.isEmpty())
+ }
+
+ @Test
+ fun onFilesPicked_whenNonEmpty_callsVm_withSameUris_orderPreserved() {
+ val uris = listOf("u1", "u2", "u3")
+ val effects = SendScreenUiLogic.onFilesPicked(uris)
+ assertTrue(effects.shouldCallOnFileChosen)
+ assertEquals(uris, effects.passedUris)
+ }
+
+ // ---------- swipe confirm logic ----------
+ @Test
+ fun onSwipeConfirm_endToStart_deletes_fadesOut_delays300_andReturnsTrue() {
+ val effects = SendScreenUiLogic.onSwipeConfirm(SendScreenUiLogic.SwipeValue.EndToStart)
+ assertTrue(effects.shouldDelete)
+ assertTrue(effects.confirmReturnValue)
+ assertTrue(effects.shouldFadeOut)
+ assertEquals(300L, effects.delayMsBeforeDelete)
+ }
+
+ @Test
+ fun onSwipeConfirm_startToEnd_doesNothing_andReturnsFalse() {
+ val effects = SendScreenUiLogic.onSwipeConfirm(SendScreenUiLogic.SwipeValue.StartToEnd)
+ assertFalse(effects.shouldDelete)
+ assertFalse(effects.confirmReturnValue)
+ assertFalse(effects.shouldFadeOut)
+ assertNull(effects.delayMsBeforeDelete)
+ }
+
+ @Test
+ fun onSwipeConfirm_settled_doesNothing_andReturnsFalse() {
+ val effects = SendScreenUiLogic.onSwipeConfirm(SendScreenUiLogic.SwipeValue.Settled)
+ assertFalse(effects.shouldDelete)
+ assertFalse(effects.confirmReturnValue)
+ assertFalse(effects.shouldFadeOut)
+ assertNull(effects.delayMsBeforeDelete)
+ }
+
+ // ---------- autoConvertByte ----------
+ @Test
+ fun autoConvertByte_zero_is0B() {
+ assertEquals("0B", SendScreenUiLogic.autoConvertByte(0))
+ }
+
+ @Test
+ fun autoConvertByte_under1MB_outputsKB_withExactImplementationLogic() {
+ // 1024 bytes = 1KB
+ assertEquals("1.0KB", SendScreenUiLogic.autoConvertByte(1024))
+
+ // 1536 bytes = 1.5KB
+ assertEquals("1.5KB", SendScreenUiLogic.autoConvertByte(1536))
+
+ // 1100 bytes
+ val result = SendScreenUiLogic.autoConvertByte(1100)
+ assertTrue(result.endsWith("KB"))
+ }
+
+ @Test
+ fun autoConvertByte_rounding_examples() {
+ // 1100 bytes => 1.07KB (1100/1024=1.074.. -> round 1.07)
+ assertEquals("1.07KB", SendScreenUiLogic.autoConvertByte(1100))
+ }
+
+ // ---------- autoConvertMS ----------
+
+ @Test
+ fun autoConvertMS_atLeast1s_andLessThan1m_outputsSeconds() {
+ assertEquals("1.0s", SendScreenUiLogic.autoConvertMS(1000))
+ assertEquals("1.5s", SendScreenUiLogic.autoConvertMS(1500))
+ // 59 seconds
+ val s = SendScreenUiLogic.autoConvertMS(59_000)
+ assertTrue(s.endsWith("s"))
+ }
+
+ @Test
+ fun autoConvertMS_atLeast1m_outputsMinutes() {
+ assertEquals("1.0m", SendScreenUiLogic.autoConvertMS(60_000))
+ // 90 seconds => 1.5m (because second=90.0, minute=1.5)
+ assertEquals("1.5m", SendScreenUiLogic.autoConvertMS(90_000))
+ }
+
+ @Test
+ fun autoConvertMS_rounding_examples() {
+ // 1234ms -> 1.23s (rounded to 2 decimals)
+ assertEquals("1.23s", SendScreenUiLogic.autoConvertMS(1234))
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/greybox/projectmesh/views/SettingsScreenUiLogic.kt b/app/src/test/java/com/greybox/projectmesh/views/SettingsScreenUiLogic.kt
new file mode 100644
index 000000000..0db5b2630
--- /dev/null
+++ b/app/src/test/java/com/greybox/projectmesh/views/SettingsScreenUiLogic.kt
@@ -0,0 +1,237 @@
+// File: app/src/test/java/com/greybox/projectmesh/views/SettingsScreenUiLogicTest.kt
+package com.greybox.projectmesh.views
+
+import org.junit.Assert.*
+import org.junit.Test
+
+/**
+ * Deep JVM tests for SettingsScreen.kt WITHOUT touching that file.
+ *
+ * This file has lots of Android/Compose (OpenDocumentTree, takePersistableUriPermission, Toast, Build checks).
+ * JVM tests should focus on PURE deterministic logic:
+ *
+ * 1) initial language option resolution (currentLanguage -> label)
+ * 2) theme option resolution (AppTheme.ordinal -> "System/Light/Dark")
+ * 3) folderNameToShow derivation from either content:// uri string or path string
+ * 4) concurrency section visibility condition (sdk < R)
+ * 5) device name dialog submit rule (non-blank only)
+ *
+ * Later in androidTest:
+ * - verify directory launcher result handling + persisted permissions
+ * - verify UI components, dropdown behavior, switch toggles, dialog display
+ */
+private object SettingsScreenUiLogic {
+
+ enum class AppTheme { System, Light, Dark }
+
+ data class LangItem(val code: String, val label: String)
+
+ private val langMenuItems = listOf(
+ LangItem("en", "English"),
+ LangItem("es", "Español"),
+ LangItem("cn", "简体中文"),
+ LangItem("fr", "Français"),
+ )
+
+ private val themeLabels = listOf("System", "Light", "Dark")
+
+ fun languageLabelFor(currentLanguage: String): String {
+ return langMenuItems.firstOrNull { it.code == currentLanguage }?.label ?: "English"
+ }
+
+ fun themeLabelFor(currentTheme: AppTheme): String {
+ return themeLabels[currentTheme.ordinal]
+ }
+
+ /**
+ * Mirrors folderNameToShow logic:
+ * if startsWith("content://"):
+ * Uri.decode(value).split(":").lastOrNull() ?: "Unknown"
+ * else:
+ * value.split("/").lastOrNull() ?: "Unknown"
+ *
+ * In unit tests, we avoid Android Uri.decode; we model it with a simple percent-decoder
+ * for the common %3A case.
+ */
+ fun folderNameToShow(saveToFolder: String): String {
+ return if (saveToFolder.startsWith("content://")) {
+ val decoded = pseudoDecodeUri(saveToFolder)
+ decoded.split(":").lastOrNull() ?: "Unknown"
+ } else {
+ saveToFolder.split("/").lastOrNull() ?: "Unknown"
+ }
+ }
+
+ private fun pseudoDecodeUri(s: String): String {
+ // Minimal decoding for typical SAF URIs with %3A representing ':'.
+ // Good enough for JVM tests; androidTest can validate Uri.decode.
+ return s.replace("%3A", ":").replace("%2F", "/")
+ }
+
+ fun shouldShowConcurrencySection(sdkInt: Int, sdkR: Int = 30): Boolean {
+ // Mirrors: if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { ... }
+ return sdkInt < sdkR
+ }
+
+ fun canSubmitDeviceName(inputText: String): Boolean = inputText.isNotBlank()
+
+ /**
+ * Mirrors "language selection" side-effects ordering intent:
+ * viewModel.saveLang(code); onLanguageChange(code)
+ */
+ fun languageSelectionTrace(selectedCode: String): List {
+ return listOf("saveLang:$selectedCode", "onLanguageChange:$selectedCode")
+ }
+
+ /**
+ * Mirrors "theme selection" side-effects:
+ * viewModel.saveTheme(theme); onThemeChange(theme)
+ */
+ fun themeSelectionTrace(selectedTheme: AppTheme): List {
+ return listOf("saveTheme:$selectedTheme", "onThemeChange:$selectedTheme")
+ }
+
+ /**
+ * Mirrors "auto finish switch" side-effects:
+ * viewModel.saveAutoFinish(isChecked); onAutoFinishChange(isChecked)
+ */
+ fun autoFinishTrace(isChecked: Boolean): List {
+ return listOf("saveAutoFinish:$isChecked", "onAutoFinishChange:$isChecked")
+ }
+
+ /**
+ * Mirrors "device name dialog confirm" side-effects (only if non-blank):
+ * viewModel.saveDeviceName(newName); onDeviceNameChange(newName)
+ */
+ fun deviceNameConfirmTrace(inputText: String): List {
+ if (!canSubmitDeviceName(inputText)) return emptyList()
+ return listOf("saveDeviceName:$inputText", "onDeviceNameChange:$inputText")
+ }
+
+ /**
+ * Mirrors "directory picker result" logic:
+ * if uri != null:
+ * saveSaveToFolder(uriString); onSaveToFolderChange(uriString)
+ * else:
+ * toast no directory selected (android-only)
+ */
+ fun directoryPickedTrace(uriStringOrNull: String?): List {
+ return if (uriStringOrNull != null) {
+ listOf("saveSaveToFolder:$uriStringOrNull", "onSaveToFolderChange:$uriStringOrNull")
+ } else {
+ listOf("toast:No directory selected")
+ }
+ }
+}
+
+class SettingsScreenUiLogicTest {
+
+ // ---------- language label ----------
+ @Test
+ fun languageLabelFor_knownCodes_returnCorrectLabels() {
+ assertEquals("English", SettingsScreenUiLogic.languageLabelFor("en"))
+ assertEquals("Español", SettingsScreenUiLogic.languageLabelFor("es"))
+ assertEquals("简体中文", SettingsScreenUiLogic.languageLabelFor("cn"))
+ assertEquals("Français", SettingsScreenUiLogic.languageLabelFor("fr"))
+ }
+
+ @Test
+ fun languageLabelFor_unknownCode_fallsBackToEnglish() {
+ assertEquals("English", SettingsScreenUiLogic.languageLabelFor("xx"))
+ assertEquals("English", SettingsScreenUiLogic.languageLabelFor(""))
+ }
+
+ // ---------- theme label ----------
+ @Test
+ fun themeLabelFor_matchesOrdinalMapping() {
+ assertEquals("System", SettingsScreenUiLogic.themeLabelFor(SettingsScreenUiLogic.AppTheme.System))
+ assertEquals("Light", SettingsScreenUiLogic.themeLabelFor(SettingsScreenUiLogic.AppTheme.Light))
+ assertEquals("Dark", SettingsScreenUiLogic.themeLabelFor(SettingsScreenUiLogic.AppTheme.Dark))
+ }
+
+ // ---------- folder name ----------
+ @Test
+ fun folderNameToShow_path_returnsLastSegment() {
+ assertEquals(
+ "Project Mesh",
+ SettingsScreenUiLogic.folderNameToShow("/storage/emulated/0/Download/Project Mesh")
+ )
+ assertEquals(
+ "Download",
+ SettingsScreenUiLogic.folderNameToShow("/storage/emulated/0/Download")
+ )
+ }
+
+ @Test
+ fun folderNameToShow_emptyPath_returnsUnknownLikeBehavior() {
+ // split("/").lastOrNull() on "" returns "" (not null) -> your code would return ""
+ // We mirror that.
+ assertEquals("", SettingsScreenUiLogic.folderNameToShow(""))
+ }
+
+ // ---------- concurrency visibility ----------
+ @Test
+ fun shouldShowConcurrencySection_onlyBelowR() {
+ assertTrue(SettingsScreenUiLogic.shouldShowConcurrencySection(29, sdkR = 30))
+ assertFalse(SettingsScreenUiLogic.shouldShowConcurrencySection(30, sdkR = 30))
+ assertFalse(SettingsScreenUiLogic.shouldShowConcurrencySection(33, sdkR = 30))
+ }
+
+ // ---------- device name submit rule ----------
+ @Test
+ fun canSubmitDeviceName_requiresNonBlank() {
+ assertFalse(SettingsScreenUiLogic.canSubmitDeviceName(""))
+ assertFalse(SettingsScreenUiLogic.canSubmitDeviceName(" "))
+ assertTrue(SettingsScreenUiLogic.canSubmitDeviceName("MeshNode"))
+ assertTrue(SettingsScreenUiLogic.canSubmitDeviceName(" Jai ")) // isNotBlank true
+ }
+
+ @Test
+ fun deviceNameConfirmTrace_onlyWhenNonBlank() {
+ assertEquals(emptyList(), SettingsScreenUiLogic.deviceNameConfirmTrace(""))
+ assertEquals(emptyList(), SettingsScreenUiLogic.deviceNameConfirmTrace(" "))
+ assertEquals(
+ listOf("saveDeviceName:Mesh", "onDeviceNameChange:Mesh"),
+ SettingsScreenUiLogic.deviceNameConfirmTrace("Mesh")
+ )
+ }
+
+ // ---------- side-effect ordering traces ----------
+ @Test
+ fun languageSelectionTrace_ordersSaveThenCallback() {
+ assertEquals(
+ listOf("saveLang:es", "onLanguageChange:es"),
+ SettingsScreenUiLogic.languageSelectionTrace("es")
+ )
+ }
+
+ @Test
+ fun themeSelectionTrace_ordersSaveThenCallback() {
+ assertEquals(
+ listOf("saveTheme:Dark", "onThemeChange:Dark"),
+ SettingsScreenUiLogic.themeSelectionTrace(SettingsScreenUiLogic.AppTheme.Dark)
+ )
+ }
+
+ @Test
+ fun autoFinishTrace_ordersSaveThenCallback() {
+ assertEquals(
+ listOf("saveAutoFinish:true", "onAutoFinishChange:true"),
+ SettingsScreenUiLogic.autoFinishTrace(true)
+ )
+ }
+
+ @Test
+ fun directoryPickedTrace_whenNull_toastElse_saveThenCallback() {
+ assertEquals(
+ listOf("toast:No directory selected"),
+ SettingsScreenUiLogic.directoryPickedTrace(null)
+ )
+
+ val uri = "content://x/y"
+ assertEquals(
+ listOf("saveSaveToFolder:$uri", "onSaveToFolderChange:$uri"),
+ SettingsScreenUiLogic.directoryPickedTrace(uri)
+ )
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 2f6b827a8..4351a8488 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,4 +5,14 @@ plugins {
kotlin("plugin.serialization") version "1.9.0"
kotlin("jvm") version "1.9.0"
id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
+ id("org.jetbrains.kotlinx.kover") version "0.9.3" apply false
+}
+
+java {
+ toolchain {
+ // version must be at least 11,
+ // but can be any higher as source and target compatibility are
+ // both specified in app/build.gradle.kts
+ languageVersion = JavaLanguageVersion.of(21)
+ }
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..1752a2326
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,534 @@
+{
+ "name": "Project-Mesh",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "husky": "^9.1.7",
+ "lint-staged": "^16.2.6",
+ "prettier": "^3.6.2"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz",
+ "integrity": "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
+ "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^7.1.0",
+ "string-width": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
+ "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
+ "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/lint-staged": {
+ "version": "16.2.6",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.6.tgz",
+ "integrity": "sha512-s1gphtDbV4bmW1eylXpVMk2u7is7YsrLl8hzrtvC70h4ByhcMLZFY01Fx05ZUDNuv1H8HO4E+e2zgejV1jVwNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.1",
+ "listr2": "^9.0.5",
+ "micromatch": "^4.0.8",
+ "nano-spawn": "^2.0.0",
+ "pidtree": "^0.6.0",
+ "string-argv": "^0.3.2",
+ "yaml": "^2.8.1"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ }
+ },
+ "node_modules/listr2": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cli-truncate": "^5.0.0",
+ "colorette": "^2.0.20",
+ "eventemitter3": "^5.0.1",
+ "log-update": "^6.1.0",
+ "rfdc": "^1.4.1",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/log-update": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+ "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^7.0.0",
+ "cli-cursor": "^5.0.0",
+ "slice-ansi": "^7.1.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mimic-function": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/nano-spawn": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
+ "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pidtree": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
+ "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "pidtree": "bin/pidtree.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/slice-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
+ "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..4b08fa81a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "scripts": {
+ "format": "prettier --write . --ignore-unknown",
+ "prepare": "husky install",
+ "lint-staged": "lint-staged"
+ },
+ "lint-staged": {
+ "**/*": "prettier --write --ignore-unknown"
+ },
+ "devDependencies": {
+ "husky": "^9.1.7",
+ "lint-staged": "^16.2.6",
+ "prettier": "^3.6.2"
+ }
+}
diff --git a/uml/Images/Components/DeviceStatusManager.png b/uml/Images/Components/DeviceStatusManager.png
new file mode 100644
index 000000000..e131be6e7
Binary files /dev/null and b/uml/Images/Components/DeviceStatusManager.png differ
diff --git a/uml/Images/Components/MNetLoggerAndroid.png b/uml/Images/Components/MNetLoggerAndroid.png
new file mode 100644
index 000000000..524812fe3
Binary files /dev/null and b/uml/Images/Components/MNetLoggerAndroid.png differ
diff --git a/uml/Images/Components/ViewModel/HomeScreenViewModel.png b/uml/Images/Components/ViewModel/HomeScreenViewModel.png
new file mode 100644
index 000000000..1e4606d7c
Binary files /dev/null and b/uml/Images/Components/ViewModel/HomeScreenViewModel.png differ
diff --git a/uml/Images/Components/ViewModel/SelectdestNScreenVm.png b/uml/Images/Components/ViewModel/SelectdestNScreenVm.png
new file mode 100644
index 000000000..d74cef83b
Binary files /dev/null and b/uml/Images/Components/ViewModel/SelectdestNScreenVm.png differ
diff --git a/uml/Images/Components/ViewModel/logscreenvm.png b/uml/Images/Components/ViewModel/logscreenvm.png
new file mode 100644
index 000000000..9762a50a4
Binary files /dev/null and b/uml/Images/Components/ViewModel/logscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/networkscreenvm.png b/uml/Images/Components/ViewModel/networkscreenvm.png
new file mode 100644
index 000000000..683efdd91
Binary files /dev/null and b/uml/Images/Components/ViewModel/networkscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/onboardingscreenvm.png b/uml/Images/Components/ViewModel/onboardingscreenvm.png
new file mode 100644
index 000000000..fe9cdee01
Binary files /dev/null and b/uml/Images/Components/ViewModel/onboardingscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/pingscreenvm.png b/uml/Images/Components/ViewModel/pingscreenvm.png
new file mode 100644
index 000000000..f8c8fac63
Binary files /dev/null and b/uml/Images/Components/ViewModel/pingscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/recievescreenvm.png b/uml/Images/Components/ViewModel/recievescreenvm.png
new file mode 100644
index 000000000..b07a8e94e
Binary files /dev/null and b/uml/Images/Components/ViewModel/recievescreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/sendscreenvm.png b/uml/Images/Components/ViewModel/sendscreenvm.png
new file mode 100644
index 000000000..34d65bfdd
Binary files /dev/null and b/uml/Images/Components/ViewModel/sendscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/settingscreenvm.png b/uml/Images/Components/ViewModel/settingscreenvm.png
new file mode 100644
index 000000000..53c8478e1
Binary files /dev/null and b/uml/Images/Components/ViewModel/settingscreenvm.png differ
diff --git a/uml/Images/Components/ViewModel/sharedUrlvm.png b/uml/Images/Components/ViewModel/sharedUrlvm.png
new file mode 100644
index 000000000..4d118194e
Binary files /dev/null and b/uml/Images/Components/ViewModel/sharedUrlvm.png differ
diff --git a/uml/Images/Components/components/wificonnecction.png b/uml/Images/Components/components/wificonnecction.png
new file mode 100644
index 000000000..94071558f
Binary files /dev/null and b/uml/Images/Components/components/wificonnecction.png differ
diff --git a/uml/Images/Components/db/database.png b/uml/Images/Components/db/database.png
new file mode 100644
index 000000000..545766657
Binary files /dev/null and b/uml/Images/Components/db/database.png differ
diff --git a/uml/Images/Components/debug/crashhandler.png b/uml/Images/Components/debug/crashhandler.png
new file mode 100644
index 000000000..1e0384104
Binary files /dev/null and b/uml/Images/Components/debug/crashhandler.png differ
diff --git a/uml/Images/Components/debug/crashscreenactivity.png b/uml/Images/Components/debug/crashscreenactivity.png
new file mode 100644
index 000000000..75c5a078c
Binary files /dev/null and b/uml/Images/Components/debug/crashscreenactivity.png differ
diff --git a/uml/Images/Components/extension/ContentExt.png b/uml/Images/Components/extension/ContentExt.png
new file mode 100644
index 000000000..71b1dd424
Binary files /dev/null and b/uml/Images/Components/extension/ContentExt.png differ
diff --git a/uml/Images/Components/extension/ContentResolverExtension.png b/uml/Images/Components/extension/ContentResolverExtension.png
new file mode 100644
index 000000000..80ebc9694
Binary files /dev/null and b/uml/Images/Components/extension/ContentResolverExtension.png differ
diff --git a/uml/Images/Components/extension/listExtension.png b/uml/Images/Components/extension/listExtension.png
new file mode 100644
index 000000000..f5e29aa73
Binary files /dev/null and b/uml/Images/Components/extension/listExtension.png differ
diff --git a/uml/Images/Components/extension/networkUtils.png b/uml/Images/Components/extension/networkUtils.png
new file mode 100644
index 000000000..d5495a880
Binary files /dev/null and b/uml/Images/Components/extension/networkUtils.png differ
diff --git a/uml/Images/Components/extension/wifilistitem.png b/uml/Images/Components/extension/wifilistitem.png
new file mode 100644
index 000000000..d072414e8
Binary files /dev/null and b/uml/Images/Components/extension/wifilistitem.png differ
diff --git a/uml/Images/Components/messaging/Conversation.png b/uml/Images/Components/messaging/Conversation.png
new file mode 100644
index 000000000..3afbfcb93
Binary files /dev/null and b/uml/Images/Components/messaging/Conversation.png differ
diff --git a/uml/Images/Components/messaging/ConversationDao.png b/uml/Images/Components/messaging/ConversationDao.png
new file mode 100644
index 000000000..8d3f19628
Binary files /dev/null and b/uml/Images/Components/messaging/ConversationDao.png differ
diff --git a/uml/Images/Components/messaging/ConversationRepository.png b/uml/Images/Components/messaging/ConversationRepository.png
new file mode 100644
index 000000000..fb7ed22d4
Binary files /dev/null and b/uml/Images/Components/messaging/ConversationRepository.png differ
diff --git a/uml/Images/Components/messaging/JSONSchema.png b/uml/Images/Components/messaging/JSONSchema.png
new file mode 100644
index 000000000..5f3e34636
Binary files /dev/null and b/uml/Images/Components/messaging/JSONSchema.png differ
diff --git a/uml/Images/Components/messaging/Message.png b/uml/Images/Components/messaging/Message.png
new file mode 100644
index 000000000..7418ada03
Binary files /dev/null and b/uml/Images/Components/messaging/Message.png differ
diff --git a/uml/Images/Components/messaging/MessageDao.png b/uml/Images/Components/messaging/MessageDao.png
new file mode 100644
index 000000000..b07f55e7d
Binary files /dev/null and b/uml/Images/Components/messaging/MessageDao.png differ
diff --git a/uml/Images/Components/messaging/MessageNetworkHandler.png b/uml/Images/Components/messaging/MessageNetworkHandler.png
new file mode 100644
index 000000000..fa7577f82
Binary files /dev/null and b/uml/Images/Components/messaging/MessageNetworkHandler.png differ
diff --git a/uml/Images/Components/messaging/MessageRepository.png b/uml/Images/Components/messaging/MessageRepository.png
new file mode 100644
index 000000000..59957c0be
Binary files /dev/null and b/uml/Images/Components/messaging/MessageRepository.png differ
diff --git a/uml/Images/Components/messaging/MessageService.png b/uml/Images/Components/messaging/MessageService.png
new file mode 100644
index 000000000..f64adf556
Binary files /dev/null and b/uml/Images/Components/messaging/MessageService.png differ
diff --git a/uml/Images/Components/messaging/messagingModels.png b/uml/Images/Components/messaging/messagingModels.png
new file mode 100644
index 000000000..25e97ca70
Binary files /dev/null and b/uml/Images/Components/messaging/messagingModels.png differ
diff --git a/uml/Images/Components/messaging/messagingScreens.png b/uml/Images/Components/messaging/messagingScreens.png
new file mode 100644
index 000000000..11a573b23
Binary files /dev/null and b/uml/Images/Components/messaging/messagingScreens.png differ
diff --git a/uml/Images/Components/messaging/messagingVM.png b/uml/Images/Components/messaging/messagingVM.png
new file mode 100644
index 000000000..2e1dfe032
Binary files /dev/null and b/uml/Images/Components/messaging/messagingVM.png differ
diff --git a/uml/Images/Components/navigation/bottomnav.png b/uml/Images/Components/navigation/bottomnav.png
new file mode 100644
index 000000000..331ab26dc
Binary files /dev/null and b/uml/Images/Components/navigation/bottomnav.png differ
diff --git a/uml/Images/Components/navigation/navitem.png b/uml/Images/Components/navigation/navitem.png
new file mode 100644
index 000000000..65476a21f
Binary files /dev/null and b/uml/Images/Components/navigation/navitem.png differ
diff --git a/uml/Images/Components/server/appserver.png b/uml/Images/Components/server/appserver.png
new file mode 100644
index 000000000..0fab8da53
Binary files /dev/null and b/uml/Images/Components/server/appserver.png differ
diff --git a/uml/Images/Components/testing/TestDeviceEntry.png b/uml/Images/Components/testing/TestDeviceEntry.png
new file mode 100644
index 000000000..3ae6784ea
Binary files /dev/null and b/uml/Images/Components/testing/TestDeviceEntry.png differ
diff --git a/uml/Images/Components/testing/TestDeviceService.png b/uml/Images/Components/testing/TestDeviceService.png
new file mode 100644
index 000000000..6a570d9ce
Binary files /dev/null and b/uml/Images/Components/testing/TestDeviceService.png differ
diff --git a/uml/Images/Components/ui.theme/ui_theme1.png b/uml/Images/Components/ui.theme/ui_theme1.png
new file mode 100644
index 000000000..e7ed35385
Binary files /dev/null and b/uml/Images/Components/ui.theme/ui_theme1.png differ
diff --git a/uml/Images/Components/user/UserDao.png b/uml/Images/Components/user/UserDao.png
new file mode 100644
index 000000000..28a13e6cf
Binary files /dev/null and b/uml/Images/Components/user/UserDao.png differ
diff --git a/uml/Images/Components/user/UserEntity.png b/uml/Images/Components/user/UserEntity.png
new file mode 100644
index 000000000..458ce8432
Binary files /dev/null and b/uml/Images/Components/user/UserEntity.png differ
diff --git a/uml/Images/Components/user/UserRepository.png b/uml/Images/Components/user/UserRepository.png
new file mode 100644
index 000000000..798146a4f
Binary files /dev/null and b/uml/Images/Components/user/UserRepository.png differ
diff --git a/uml/Images/Components/util/NotificationHelper.png b/uml/Images/Components/util/NotificationHelper.png
new file mode 100644
index 000000000..50dd04697
Binary files /dev/null and b/uml/Images/Components/util/NotificationHelper.png differ
diff --git a/uml/Images/Components/views/homescreen.png b/uml/Images/Components/views/homescreen.png
new file mode 100644
index 000000000..dca07058a
Binary files /dev/null and b/uml/Images/Components/views/homescreen.png differ
diff --git a/uml/Images/Components/views/logscreen.png b/uml/Images/Components/views/logscreen.png
new file mode 100644
index 000000000..ae2a5c4cd
Binary files /dev/null and b/uml/Images/Components/views/logscreen.png differ
diff --git a/uml/Images/Components/views/networkscreen.png b/uml/Images/Components/views/networkscreen.png
new file mode 100644
index 000000000..cc95bb1f7
Binary files /dev/null and b/uml/Images/Components/views/networkscreen.png differ
diff --git a/uml/Images/Components/views/onboardingscreen.png b/uml/Images/Components/views/onboardingscreen.png
new file mode 100644
index 000000000..f073d10f2
Binary files /dev/null and b/uml/Images/Components/views/onboardingscreen.png differ
diff --git a/uml/Images/Components/views/pingscreen.png b/uml/Images/Components/views/pingscreen.png
new file mode 100644
index 000000000..3dafc5ab7
Binary files /dev/null and b/uml/Images/Components/views/pingscreen.png differ
diff --git a/uml/Images/Components/views/recievescreen-1.png b/uml/Images/Components/views/recievescreen-1.png
new file mode 100644
index 000000000..677dce6f5
Binary files /dev/null and b/uml/Images/Components/views/recievescreen-1.png differ
diff --git a/uml/Images/Components/views/requestpermissionscreen.png b/uml/Images/Components/views/requestpermissionscreen.png
new file mode 100644
index 000000000..a5d1e218a
Binary files /dev/null and b/uml/Images/Components/views/requestpermissionscreen.png differ
diff --git a/uml/Images/Components/views/selectdestnodescreen.png b/uml/Images/Components/views/selectdestnodescreen.png
new file mode 100644
index 000000000..7540f4163
Binary files /dev/null and b/uml/Images/Components/views/selectdestnodescreen.png differ
diff --git a/uml/Images/Components/views/sendscreen.png b/uml/Images/Components/views/sendscreen.png
new file mode 100644
index 000000000..6160dbce7
Binary files /dev/null and b/uml/Images/Components/views/sendscreen.png differ
diff --git a/uml/Images/Components/views/settingscreen.png b/uml/Images/Components/views/settingscreen.png
new file mode 100644
index 000000000..cf3048114
Binary files /dev/null and b/uml/Images/Components/views/settingscreen.png differ
diff --git a/uml/class-diagrams/components/DeviceStatusManager.puml b/uml/class-diagrams/components/DeviceStatusManager.puml
new file mode 100644
index 000000000..d1c9acc8d
--- /dev/null
+++ b/uml/class-diagrams/components/DeviceStatusManager.puml
@@ -0,0 +1,53 @@
+@startuml DeviceStatusManager
+skinparam classAttributeIconSize 0
+
+class DeviceStatusManager <> {
+ - _deviceStatusMap : MutableStateFlow>
+ - lastCheckedTimes : MutableMap
+ - failureCountMap : MutableMap
+ - appServer : AppServer?
+ + initialize(server: AppServer)
+ + updateDeviceStatus(ipAddress: String, isOnline: Boolean, verified: Boolean = false)
+ + isDeviceOnline(ipAddress: String) : Boolean
+ + verifyDeviceStatus(ipAddress: String)
+ + handleNetworkDisconnect(ipAddress: String)
+ + getOnlineDevices() : List
+ + clearAllStatuses()
+ - updateConversations(ipAddress: String, isOnline: Boolean)
+ - startPeriodicStatusChecks()
+}
+
+class AppServer {
+ + checkDeviceReachable(addr: InetAddress) : Boolean
+ + requestRemoteUserInfo(addr: InetAddress)
+}
+
+class GlobalApp {
+ {static} GlobalUserRepo : GlobalUserRepo
+}
+
+class GlobalUserRepo {
+ + userRepository : UserRepository
+ + conversationRepository : ConversationRepository
+}
+
+class UserRepository {
+ + getUserByIp(ip: String) : UserEntity?
+}
+
+class ConversationRepository {
+ + updateUserStatus(userUuid: String, isOnline: Boolean, userAddress: String?)
+}
+
+class UserEntity
+
+class InetAddress
+
+DeviceStatusManager --> AppServer : uses
+DeviceStatusManager ..> InetAddress : resolves/reachability
+DeviceStatusManager ..> GlobalApp : reads static repo
+GlobalApp --> GlobalUserRepo
+GlobalUserRepo --> UserRepository
+GlobalUserRepo --> ConversationRepository
+UserRepository --> UserEntity
+@enduml
diff --git a/uml/class-diagrams/components/MNetLoggerAndroid.puml b/uml/class-diagrams/components/MNetLoggerAndroid.puml
new file mode 100644
index 000000000..bb275fafc
--- /dev/null
+++ b/uml/class-diagrams/components/MNetLoggerAndroid.puml
@@ -0,0 +1,44 @@
+@startuml
+title MNetLoggerAndroid - Class Diagram
+
+skinparam classAttributeIconSize 0
+hide empty members
+set namespaceSeparator none
+
+class MNetLoggerAndroid {
+ -deviceInfo: String
+ -minLogLevel: Int
+ -logHistoryLines: Int
+ -logFile: File?
+ -_recentLogs: MutableStateFlow