diff --git a/.github/workflows/build_artifacts.yml b/.github/workflows/build_artifacts.yml new file mode 100644 index 000000000..539c3d202 --- /dev/null +++ b/.github/workflows/build_artifacts.yml @@ -0,0 +1,103 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle + +name: Build APKs +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run anytime a PR is merged to main or a direct push to main + push: + branches: [main] + + # run on any push to a PR branch + pull_request: + + # run on demand + workflow_dispatch: + inputs: + custom-run-name: + description: "Custom name for this Actions run" + required: false + type: string + +# cancel any previously-started, yet still active runs of this workflow on the same branch +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Notify User to Scroll + run: | + echo "## Scroll to the end of the page for artifacts :arrow_down:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew build + + - name: Upload App Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug-apk # Optional: Name your artifact + path: app/build/outputs/apk/debug/app-debug.apk # Path to your build output (e.g., JARs, WARs) + retention-days: 1 # Optional: Set a custom retention period in days + + - name: Upload App Release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-apk # Optional: Name your artifact + path: app/build/outputs/apk/release/app-release-unsigned.apk # Path to your build output (e.g., JARs, WARs) + retention-days: 1 # Optional: Set a custom retention period in days + # NOTE: The Gradle Wrapper is the default and recommended way to run Gradle (https://docs.gradle.org/current/userguide/gradle_wrapper.html). + # If your project does not have the Gradle Wrapper configured, you can use the following configuration to run Gradle with a specified version. + # + # - name: Setup Gradle + # uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + # with: + # gradle-version: '8.9' + # + # - name: Build with Gradle 8.9 + # run: gradle build + + dependency-submission: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: "temurin" + + # Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies. + # See: https://github.com/gradle/actions/blob/main/dependency-submission/README.md + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 diff --git a/.github/workflows/format_and_lint.yml b/.github/workflows/format_and_lint.yml new file mode 100644 index 000000000..9700d5dd6 --- /dev/null +++ b/.github/workflows/format_and_lint.yml @@ -0,0 +1,56 @@ +--- +# template source: https://github.com/bretfisher/super-linter-workflow/blob/main/templates/call-super-linter.yaml +name: Lint Code Base +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run anytime a PR is merged to main or a direct push to main + push: + branches: [main] + + # run on any push to a PR branch + pull_request: + + # run on demand + workflow_dispatch: + inputs: + custom-run-name: + description: "Custom name for this Actions run" + required: false + type: string + +# cancel any previously-started, yet still active runs of this workflow on the same branch +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +permissions: read-all + +# adapted from https://mskelton.medium.com/auto-formatting-code-using-prettier-and-github-actions-ed458f58b7df +jobs: + call-super-linter: + # needs: format + name: Call Super-Linter + + permissions: + contents: read # clone the repo to lint + statuses: write # read/write to repo custom statuses + + ### use Reusable Workflows to call my workflow remotely + ### https://docs.github.com/en/actions/learn-github-actions/reusing-workflows + ### you can also call workflows from inside the same repo via file path + + # FIXME: customize uri to point to your own reusable linter repository + uses: polygeist111/super-linter-workflow/.github/workflows/reusable-super-linter.yaml@main + # TODO: update url to point to gb reusable linter, not thalia's + + ### Optional settings examples + + # with: + ### For a DevOps-focused repository. Prevents some code-language linters from running + ### defaults to false + # devops-only: false + + ### A regex to exclude files from linting + ### defaults to empty + # filter-regex-exclude: diff --git a/.github/workflows/report_kotlin_coverage.yml b/.github/workflows/report_kotlin_coverage.yml new file mode 100644 index 000000000..b011d3411 --- /dev/null +++ b/.github/workflows/report_kotlin_coverage.yml @@ -0,0 +1,67 @@ +name: Measure Test Coverage +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run anytime a PR is merged to main or a direct push to main + push: + branches: [main] + + # run on any push to a PR branch + pull_request: + + # run on demand + workflow_dispatch: + inputs: + custom-run-name: + description: "Custom name for this Actions run" + required: false + type: string + +# cancel any previously-started, yet still active runs of this workflow on the same branch +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Define Coverage Minimums + run: | + echo "min-coverage-overall=80" >> $GITHUB_ENV + echo "min-coverage-changed-files=80" >> $GITHUB_ENV + - uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "21" + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Generate kover coverage report + run: ./gradlew koverXmlReport + - name: Add coverage report to PR + id: kover + uses: mi-kas/kover-report@v1 + with: + path: | + ${{ github.workspace }}/app/build/reports/kover/report.xml + title: Code Coverage + update-comment: true + min-coverage-overall: ${{ env.min-coverage-overall }} + min-coverage-changed-files: ${{ env.min-coverage-changed-files }} + coverage-counter-type: LINE + - name: Add short summary to workflow run + run: | + echo "| Type | Coverage | Passing |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| Overall Coverage | ${{ steps.kover.outputs.coverage-overall }}% | ${{ steps.kover.outputs.coverage-overall >= env.min-coverage-overall && ':white_check_mark:' || ':x:' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Changed File Coverage | ${{ steps.kover.outputs.coverage-changed-files }}% | ${{ steps.kover.outputs.coverage-changed-files >= env.min-coverage-changed-files && ':white_check_mark:' || ':x:' }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "(Changed File Coverage is currently bugged, disregard)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 6e1af9e57..6f2c6ad72 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ kotlin-ide/ .idea/ .aider* .env +.idea/misc.xml +.vscode/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..0100eae14 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint-staged diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b86273d94..b589d56e9 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 0bd3ec25a..3b0be2284 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,7 @@ + - + + 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. + +
+ +![Super-Linter](https://github.com/polygeist111/project-mesh/actions/workflows/format_and_lint.yml/badge.svg) +![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square) + +
# 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: FirstConvoScreen -* 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 Initial Chat Screen -* 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: RecievedMessageConvoScreen @@ -226,7 +269,7 @@ fun sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?) { RecievedMessageChatScreen -3. When Going back to the Chat Screen Read Status is Updated: +3. When Going back to the Chat Screen Read Status is Updated: ReadIndicatorsUpdating @@ -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: OnlineTestChat ### 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 User_Onboarding 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: NetworkScreenPostUpdate -```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_: UserNameSettings -*User name can be Updated* : +_User name can be Updated_ : EditingUserName -```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> + -logChannel: Channel + +recentLogs: Flow> + +invoke(priority: Int, message: String, exception: Exception?): Unit + +invoke(priority: Int, message: () -> String, exception: Exception?): Unit + +exportAsString(context: Context): String +} + +class LogLine +class Context +class File +class Flow +class MutableStateFlow +class Channel + +MNetLoggerAndroid --> LogLine +MNetLoggerAndroid --> Context +MNetLoggerAndroid --> File +MNetLoggerAndroid --> Flow +MNetLoggerAndroid --> MutableStateFlow +MNetLoggerAndroid --> Channel + +note right of MNetLoggerAndroid +Tests validate: +- log-level filtering +- newest-first history updates +- trimming to max history length +- optional file persistence +- export formatting with device info +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/Onboardingscreenvm.puml b/uml/class-diagrams/components/ViewModel/Onboardingscreenvm.puml new file mode 100644 index 000000000..4225c2c2e --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/Onboardingscreenvm.puml @@ -0,0 +1,57 @@ +@startuml +skinparam classAttributeIconSize 0 + +package "com.greybox.projectmesh.viewModel" { + + class OnboardingViewModel <> { + - userRepository: UserRepository + - prefs: SharedPreferences + - localIp: String + -- + - _uiState: MutableStateFlow + + uiState: StateFlow + -- + + onUsernameChange(newUsername: String): Unit + + handleFirstTimeSetup(onComplete: () -> Unit): Unit + + blankUsernameGenerator(onResult: (String) -> Unit): Unit + } + + class OnboardingUiState <> { + + username: String + } + + interface UserRepository { + + insertOrUpdateUser(uuid: String, name: String?, address: String?): Unit + + getAllUsers(): List + } + + class User { + + name: String? + } + + interface SharedPreferences { + + getString(key: String, defValue: String?): String? + + edit(): SharedPreferences.Editor + } + + interface "SharedPreferences.Editor" as Editor { + + putString(key: String, value: String?): Editor + + putBoolean(key: String, value: Boolean): Editor + + apply(): Unit + } + + class UUID <> { + {static} + randomUUID(): UUID + + toString(): String + } + + OnboardingViewModel --> OnboardingUiState : uses/updates\n_state + OnboardingViewModel --> UserRepository : calls + OnboardingViewModel --> SharedPreferences : reads/writes + SharedPreferences --> Editor : creates + OnboardingViewModel ..> UUID : generates id + UserRepository --> User : returns list + +} + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/PingScreenvm.puml b/uml/class-diagrams/components/ViewModel/PingScreenvm.puml new file mode 100644 index 000000000..1de455746 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/PingScreenvm.puml @@ -0,0 +1,41 @@ +@startuml +title PingScreenViewModel (Kotlin) - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members + +package "com.greybox.projectmesh.viewModel" { + + class PingScreenModel <> { + +deviceName: String? + +virtualAddress: InetAddress + +allOriginatorMessages: List + } + + class PingScreenViewModel <> { + -_uiState: MutableStateFlow + +uiState: Flow + -node: AndroidVirtualNode + -appServer: AppServer + -lastTimeReceived: Long + -- + +init() + } +} + +' --- Relationships / dependencies --- +PingScreenViewModel --> PingScreenModel : exposes/updates +PingScreenViewModel ..> DI : injected +PingScreenViewModel ..> SavedStateHandle : ctor +PingScreenViewModel ..> InetAddress : ctor +PingScreenViewModel ..> AndroidVirtualNode : collects state +PingScreenViewModel ..> AppServer : uses +PingScreenModel ..> InetAddress +PingScreenModel ..> "VirtualNode.LastOriginatorMessage" + +note right of PingScreenViewModel +init launches a coroutine (viewModelScope.launch) +collects node.state and updates _uiState +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/SelectdestNScreenVm.puml b/uml/class-diagrams/components/ViewModel/SelectdestNScreenVm.puml new file mode 100644 index 000000000..a16684773 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/SelectdestNScreenVm.puml @@ -0,0 +1,50 @@ +@startuml +title SelectDestNodeScreenViewModel - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members + +package "com.greybox.projectmesh.viewModel" { + + class SelectDestNodeScreenModel <> { + +allNodes: Map + +uris: List + +contactingInProgressDevice: String + } + + class SelectDestNodeScreenViewModel <> { + -sendUris: List + -popBackWhenDone: Function0 + -- + -_uiState: MutableStateFlow + +uiState: Flow + -appServer: AppServer + -node: AndroidVirtualNode + -- + +onClickReceiver(address: int) + } +} + +SelectDestNodeScreenViewModel --> SelectDestNodeScreenModel : updates +SelectDestNodeScreenViewModel ..> AppServer +SelectDestNodeScreenViewModel ..> AndroidVirtualNode +SelectDestNodeScreenViewModel ..> Uri +SelectDestNodeScreenViewModel ..> InetAddress +SelectDestNodeScreenModel ..> "VirtualNode.LastOriginatorMessage" + +note right of SelectDestNodeScreenViewModel +init: +- uris = sendUris +- collect node.state +- allNodes = originatorMessages +end note + +note bottom of SelectDestNodeScreenViewModel +onClickReceiver: +- inetAddress = InetAddress.getByAddress(address.addressToByteArray()) +- contactingInProgressDevice = address.addressToDotNotation() +- sendUris async -> appServer.addOutgoingTransfer(uri, inetAddress) +- if any success -> popBackWhenDone() +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/homescreen.puml b/uml/class-diagrams/components/ViewModel/homescreen.puml new file mode 100644 index 000000000..09ad4b11c --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/homescreen.puml @@ -0,0 +1,172 @@ +@startuml HomeScreenViewModel +skinparam classAttributeIconSize 0 +hide empty members + +package "com.greybox.projectmesh.viewModel" { + + class HomeScreenModel <> { + +wifiState: MeshrabiyaWifiState? + +connectUri: String? + +localAddress: Int + +bandMenu: List + +band: ConnectBand + +hotspotTypeMenu: List + +hotspotTypeToCreate: HotspotType + +hotspotStatus: Boolean + +isWifiConnected: Boolean + +nodesOnMesh: Set + -- + +wifiConnectionEnabled: Boolean {get} + +connectBandVisible: Boolean {get} + } + + class HomeScreenViewModel <> { + -settingPrefs: SharedPreferences + -node: AndroidVirtualNode + + -_uiState: MutableStateFlow + +uiState: Flow + + -_concurrencyKnown: MutableStateFlow + +concurrencyKnown: StateFlow + + -_concurrencySupported: MutableStateFlow + +concurrencySupported: StateFlow + + -sharedPrefsListener: OnSharedPreferenceChangeListener + + -_showNoConcurrencyWarning: MutableStateFlow + +showNoConcurrencyWarning: StateFlow + + -_showConcurrencyWarning: MutableStateFlow + +showConcurrencyWarning: StateFlow + + -- + +saveConcurrencyKnown(concurrencyKnown: Boolean): Unit + +saveConcurrencySupported(concurrencySupported: Boolean): Unit + +onConnectBandChanged(band: ConnectBand): Unit + +onSetHotspotTypeToCreate(hotspotType: HotspotType): Unit + +onSetIncomingConnectionsEnabled(enable: Boolean): Unit + +onConnectWifi(hotSpotConfig: WifiConnectConfig): Unit + +onClickDisconnectStation(): Unit + +dismissNoConcurrencyWarning(): Unit + +dismissConcurrencyWarning(): Unit + +onCleared(): Unit + + -- + -loadConcurrencyKnown(): Boolean + -loadConcurrencySupported(): Boolean + -markStaApConcurrencyUnsupported(): Unit + -markStaApConcurrencySupported(): Unit + + -- + {static} CONCURRENCY_KNOWN_KEY: String + {static} CONCURRENCY_SUPPORTED_KEY: String + } + + ' ---- External / referenced types (kept as stubs) ---- + class SharedPreferences <> + interface OnSharedPreferenceChangeListener <> + class SavedStateHandle <> + class ViewModel <> + + class AndroidVirtualNode <> { + +state: Flow + +meshrabiyaWifiManager: MeshrabiyaWifiManager + +setWifiHotspotEnabled(enabled: Boolean, preferredBand: ConnectBand, hotspotType: HotspotType): Any + +connectAsStation(config: WifiConnectConfig): Unit + +disconnectWifiStation(): Unit + } + + class NodeState <> { + +wifiState: MeshrabiyaWifiState + +connectUri: String + +address: Int + +originatorMessages: Map + } + + class MeshrabiyaWifiManager <> { + +is5GhzSupported: Boolean + } + + class MeshrabiyaWifiState <> { + +connectConfig: Any + +hotspotIsStarted: Boolean + +wifiStationState: WifiStationState + } + + class WifiStationState <> { + +status: Status + } + + enum Status { + AVAILABLE + INACTIVE + } + + enum ConnectBand { + BAND_2GHZ + BAND_5GHZ + } + + enum HotspotType { + AUTO + WIFIDIRECT_GROUP + LOCALONLY_HOTSPOT + } + + class WifiConnectConfig <> + class DI <> + class MutableStateFlow <> + interface StateFlow <> + interface Flow <> +} + +' ---- Relationships ---- +HomeScreenViewModel -|> ViewModel +HomeScreenViewModel o--> SharedPreferences : injects (tag="settings") +HomeScreenViewModel o--> AndroidVirtualNode : injects +HomeScreenViewModel ..> DI : ctor(di) +HomeScreenViewModel ..> SavedStateHandle : ctor(savedStateHandle) + +HomeScreenViewModel --> HomeScreenModel : updates state +HomeScreenViewModel o--> OnSharedPreferenceChangeListener : registers/unregisters + +AndroidVirtualNode --> NodeState : emits +NodeState --> MeshrabiyaWifiState +MeshrabiyaWifiState --> WifiStationState +WifiStationState --> Status + +HomeScreenModel ..> MeshrabiyaWifiState +HomeScreenModel ..> ConnectBand +HomeScreenModel ..> HotspotType + +HomeScreenViewModel ..> WifiConnectConfig +HomeScreenViewModel ..> ConnectBand +HomeScreenViewModel ..> HotspotType + +' ---- Behavioral notes ---- +note right of HomeScreenViewModel +init: +- collect node.state in viewModelScope + -> _uiState.update(prev.copy(...)) +- if node.meshrabiyaWifiManager.is5GhzSupported + -> bandMenu=[5GHz,2GHz], band=5GHz +- register SharedPreferences listener + +Prefs listener: +- on CONCURRENCY_* change: + _concurrencyKnown = loadConcurrencyKnown() + _concurrencySupported = loadConcurrencySupported() +end note + +note bottom of HomeScreenViewModel +STA/AP concurrency detection (SDK < R): +- on hotspot enable OR connect station: + delay(500) + compare before/after connectivity + -> mark supported/unsupported + -> show warning popup StateFlows +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/networkscreenvm.puml b/uml/class-diagrams/components/ViewModel/networkscreenvm.puml new file mode 100644 index 000000000..37c6a4477 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/networkscreenvm.puml @@ -0,0 +1,75 @@ +@startuml +title NetworkScreenViewModel - Class + Flow diagram + +skinparam classAttributeIconSize 0 + +package "com.greybox.projectmesh.viewModel" { + + class NetworkScreenModel <> { + +connectingInProgressSsid: String? + +allNodes: Map + } + + class NetworkScreenViewModel <> { + -_uiState: MutableStateFlow + +uiState: Flow + -node: AndroidVirtualNode + -appServer: AppServer + + +NetworkScreenViewModel(di: DI, savedStateHandle: SavedStateHandle) + +getDeviceName(wifiAddress: Int): Unit + } +} + +package "External / Dependencies" { + class DI + class SavedStateHandle + class ViewModel + class "MutableStateFlow" as MSF + class "Flow" as FlowNSM + + class AndroidVirtualNode + class "VirtualNode.LastOriginatorMessage" as LastOriginatorMessage + class AppServer + class DeviceStatusManager + class TestDeviceEntry + class InetAddress + enum WifiStationState { + Status + } +} + +' --- Relationships --- +NetworkScreenViewModel --|> ViewModel + +NetworkScreenViewModel o-- NetworkScreenModel : holds state +NetworkScreenViewModel o-- MSF : _uiState +NetworkScreenViewModel ..> FlowNSM : exposes + +NetworkScreenViewModel ..> DI : injected via +NetworkScreenViewModel ..> SavedStateHandle : ctor param + +NetworkScreenViewModel ..> AndroidVirtualNode : uses (node.state.collect) +NetworkScreenViewModel ..> AppServer : uses (sendDeviceName) +NetworkScreenViewModel ..> DeviceStatusManager : updateDeviceStatus()/handleNetworkDisconnect() +NetworkScreenViewModel ..> TestDeviceEntry : createTestEntry() +NetworkScreenViewModel ..> InetAddress : getByAddress() + +NetworkScreenModel ..> LastOriginatorMessage : allNodes values + +note right of NetworkScreenViewModel +init: +- collect node.state +- detect disconnected nodes via key diff +- merge originatorMessages + test device +- update _uiState (ssid when CONNECTING) +- mark devices online (verified=false) +end note + +note bottom of NetworkScreenViewModel +getDeviceName(wifiAddress): +- convert address -> InetAddress +- appServer.sendDeviceName(...) +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/recievescreenvm.puml b/uml/class-diagrams/components/ViewModel/recievescreenvm.puml new file mode 100644 index 000000000..3ec074a11 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/recievescreenvm.puml @@ -0,0 +1,52 @@ +@startuml +title ReceiveScreenViewModel (Kotlin) - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members + +package "com.greybox.projectmesh.viewModel" { + + class ReceiveScreenModel <> { + +incomingTransfers: List + } + + class ReceiveScreenViewModel <> { + -_uiState: MutableStateFlow + +uiState: Flow + -appServer: AppServer + -receiveDir: File + -- + +onAccept(transfer: AppServer.IncomingTransferInfo) + +onDecline(transfer: AppServer.IncomingTransferInfo) + +onDelete(transfer: AppServer.IncomingTransferInfo) + } +} + +ReceiveScreenViewModel --> ReceiveScreenModel : exposes/updates +ReceiveScreenViewModel ..> DI : injected +ReceiveScreenViewModel ..> SavedStateHandle : ctor +ReceiveScreenViewModel ..> AppServer : collects incomingTransfers +ReceiveScreenViewModel ..> File : receiveDir + +note right of ReceiveScreenViewModel +init: +- viewModelScope.launch +- collect appServer.incomingTransfers +- update _uiState.incomingTransfers +end note + +note bottom of ReceiveScreenViewModel +onAccept: +- launch + withContext(IO) +- ensure receiveDir exists +- create destination File +- appServer.acceptIncomingTransfer(transfer, file) + +onDecline: +- appServer.onDeclineIncomingTransfer(transfer) + +onDelete: +- appServer.onDeleteIncomingTransfer(transfer) +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/sendScreenvm.puml b/uml/class-diagrams/components/ViewModel/sendScreenvm.puml new file mode 100644 index 000000000..aeda20591 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/sendScreenvm.puml @@ -0,0 +1,54 @@ +@startuml +title SendScreenViewModel - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +package "com.greybox.projectmesh.viewModel" { + + class SendScreenModel <> { + +outgoingTransfers: List + } + + class SendScreenViewModel <> { + -onSwitchToSelectDestNode: CallbackUris + -- + -_uiState: MutableStateFlow + +uiState: Flow + -appServer: AppServer + -- + +onFileChosen(uris: List) + +onDelete(transfer: OutgoingTransferInfo) + } + + class AppServer + class OutgoingTransferInfo + class Uri + class SavedStateHandle + class DI + class Flow + class MutableStateFlow + class CallbackUris +} + +SendScreenViewModel --> SendScreenModel : updates +SendScreenViewModel ..> AppServer : uses +SendScreenViewModel ..> Uri : param +SendScreenModel ..> OutgoingTransferInfo + +note right of SendScreenViewModel +init: +- collect appServer.outgoingTransfers +- update _uiState.outgoingTransfers +end note + +note bottom of SendScreenViewModel +onFileChosen: +- onSwitchToSelectDestNode(uris) + +onDelete: +- appServer.removeOutgoingTransfer(transfer.id) +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/settingScreenVm.puml b/uml/class-diagrams/components/ViewModel/settingScreenVm.puml new file mode 100644 index 000000000..e11d52da3 --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/settingScreenVm.puml @@ -0,0 +1,81 @@ +@startuml +title SettingsScreenViewModel - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +package "com.greybox.projectmesh.viewModel" { + + class SettingsScreenViewModel <> { + -settingPrefs: SharedPreferences + -- + -_theme: MutableStateFlow + +theme: StateFlow + -- + -_lang: MutableStateFlow + +lang: StateFlow + -- + -_deviceName: MutableStateFlow + +deviceName: StateFlow + -- + -_autoFinish: MutableStateFlow + +autoFinish: StateFlow + -- + -_saveToFolder: MutableStateFlow + +saveToFolder: StateFlow + -- + -loadTheme(): AppTheme + +saveTheme(theme: AppTheme) + -- + -loadLang(): String + +saveLang(languageCode: String) + -- + -loadDeviceName(): String + +saveDeviceName(deviceName: String) + -- + -loadAutoFinish(): Boolean + +saveAutoFinish(autoFinish: Boolean) + -- + -loadSaveToFolder(): String + +saveSaveToFolder(saveToFolder: String) + -- + +updateConcurrencySettings(concurrencyKnown: Boolean, concurrencySupported: Boolean) + } + + class SharedPreferences + class AppTheme + class MutableStateFlow + class StateFlow + class SavedStateHandle + class DI + class Build + class Environment +} + +SettingsScreenViewModel ..> SharedPreferences : reads/writes prefs +SettingsScreenViewModel ..> AppTheme +SettingsScreenViewModel ..> MutableStateFlow +SettingsScreenViewModel ..> StateFlow +SettingsScreenViewModel ..> Build : default device name +SettingsScreenViewModel ..> Environment : default save folder + +note right of SettingsScreenViewModel +init: +- loads persisted values into: + theme, lang, deviceName, + autoFinish, saveToFolder +end note + +note bottom of SettingsScreenViewModel +Keys: +- app_theme +- language +- device_name +- auto_finish +- save_to_folder +- concurrency_known +- concurrency_supported +end note + +@enduml diff --git a/uml/class-diagrams/components/ViewModel/sharedUrlVm.puml b/uml/class-diagrams/components/ViewModel/sharedUrlVm.puml new file mode 100644 index 000000000..5ad45eaff --- /dev/null +++ b/uml/class-diagrams/components/ViewModel/sharedUrlVm.puml @@ -0,0 +1,32 @@ +@startuml +title SharedUriViewModel - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +package "com.greybox.projectmesh.viewModel" { + + class SharedUriViewModel <> { + -_uris: MutableStateFlow> + +uris: StateFlow> + -- + +setUris(uriList: List) + } + + class Uri + class MutableStateFlow + class StateFlow +} + +SharedUriViewModel ..> Uri +SharedUriViewModel ..> MutableStateFlow +SharedUriViewModel ..> StateFlow + +note right of SharedUriViewModel +State: +- uris starts as emptyList() +- setUris replaces the list +end note + +@enduml diff --git a/uml/class-diagrams/components/components/wificonnecction.puml b/uml/class-diagrams/components/components/wificonnecction.puml new file mode 100644 index 000000000..117fd04eb --- /dev/null +++ b/uml/class-diagrams/components/components/wificonnecction.puml @@ -0,0 +1,97 @@ +@startuml +title WifiConnection.kt – Unified Class Diagram + Test Coverage (no workflow graphics) + +skinparam packageStyle rectangle +skinparam classAttributeIconSize 0 +skinparam shadowing false +skinparam linetype ortho + +' ---- Coverage color scheme ---- +skinparam class { + BackgroundColor<> #E9FBE9 + BorderColor<> #2E7D32 + BackgroundColor<> #FFF3E0 + BorderColor<> #E65100 +} + +' ========================== +' STRUCTURE (types in file) +' ========================== +package "com.greybox.projectmesh.components" as CMP { + + ' Runtime launcher contract exercised on device/emulator + interface ConnectWifiLauncher <> + + ' Data holder (planned JVM tests: defaults, equality, copy, reference integrity) + class ConnectRequest <> { + +receivedTime: Long + +connectConfig: WifiConnectConfig + } + + ' Result model (JVM tests validate failure-shape + data-class behavior) + class ConnectWifiLauncherResult <> { + +hotspotConfig: WifiConnectConfig? + +exception: Exception? + +isWifiConnected: Boolean + } + + ' Enum surface (JVM tests assert values exist) + enum ConnectWifiLauncherStatus <> { + INACTIVE + REQUESTING_PERMISSION + LOOKING_FOR_NETWORK + REQUESTING_LINK + } + + ' Composable factory wiring Android services (instrumented tests cover runtime) + class MeshrabiyaConnectLauncher <> +} + +' ========================== +' EXTERNAL COLLABORATORS +' ========================== +package "External (Android / Libs)" as EXT { + class AndroidVirtualNode + class MNetLogger + class WifiConnectConfig + class WifiManager { + +isWifiEnabled: Boolean + +is5GHzBandSupported: Boolean + } + class CompanionDeviceManager + class AssociationRequest + class WifiDeviceFilter + class ScanResult { + +BSSID: String + } +} + +' ========================== +' RELATIONSHIPS (kept simple) +' ========================== +ConnectWifiLauncher <|.. MeshrabiyaConnectLauncher +MeshrabiyaConnectLauncher ..> AndroidVirtualNode +MeshrabiyaConnectLauncher ..> MNetLogger +MeshrabiyaConnectLauncher ..> WifiConnectConfig +MeshrabiyaConnectLauncher ..> WifiManager +MeshrabiyaConnectLauncher ..> CompanionDeviceManager +MeshrabiyaConnectLauncher ..> AssociationRequest +MeshrabiyaConnectLauncher ..> WifiDeviceFilter +MeshrabiyaConnectLauncher ..> ScanResult + +ConnectWifiLauncherResult *-- WifiConnectConfig : hotspotConfig +ConnectRequest *-- WifiConnectConfig : connectConfig + +' ========================== +' LEGEND + Icon meaning +' ========================== +legend left +|= Color |= Meaning | +|<#E9FBE9> | Covered by JVM unit tests (src/test) | +|<#FFF3E0> | Covered by instrumented tests (src/androidTest) | +|default | Not covered in current plan | +-- +I = Interface, C = Class, E = Enum +endlegend + +@enduml diff --git a/uml/class-diagrams/components/db/database.puml b/uml/class-diagrams/components/db/database.puml new file mode 100644 index 000000000..bd1893479 --- /dev/null +++ b/uml/class-diagrams/components/db/database.puml @@ -0,0 +1,79 @@ +@startuml +title MeshDatabase – Structure + Test Coverage (class diagram only) + +skinparam packageStyle rectangle +skinparam classAttributeIconSize 0 +skinparam shadowing false +skinparam linetype ortho + +' ---- Coverage color scheme (same as before) ---- +skinparam class { + BackgroundColor<> #E9FBE9 + BorderColor<> #2E7D32 + BackgroundColor<> #FFF3E0 + BorderColor<> #E65100 +} + +' ========================== +' STRUCTURE (your file) +' ========================== +package "com.greybox.projectmesh.db" as DB { + + ' Abstract RoomDatabase (JVM tests will only reflect class shape) + abstract class MeshDatabase <> { + +messageDao(): MessageDao + +userDao(): UserDao + +conversationDao(): ConversationDao + } +} + +' ========================== +' External pieces it wires +' ========================== +package "External (Room / Messaging / User)" as EXT { + abstract class RoomDatabase + interface MessageDao + interface ConversationDao + interface UserDao + + class Message + class Conversation + class UserEntity +} + +' ========================== +' Relationships +' ========================== +RoomDatabase <|-- MeshDatabase +MeshDatabase ..> MessageDao +MeshDatabase ..> ConversationDao +MeshDatabase ..> UserDao + +' Entities are referenced via @Database annotation at compile-time. +' We show dependencies for documentation (instrumented tests cover behavior). +MeshDatabase ..> Message +MeshDatabase ..> Conversation +MeshDatabase ..> UserEntity + +' ========================== +' JVM vs Instrumented notes +' ========================== +note right of MeshDatabase +RUNTIME BEHAVIOR (Instrumented): +- Room creates DB from @Database(entities=[Message, UserEntity, Conversation], version=4) +- DAOs returned by methods operate on tables +- Migrations/versioning enforced by Room + +JVM TESTS (no Android): +- Reflect that MeshDatabase is abstract and extends RoomDatabase +- Verify DAO method names & return types exist +end note + +legend left +|= Color |= Meaning | +|<#E9FBE9> | Covered by JVM unit tests (src/test) | +|<#FFF3E0> | Covered by instrumented tests (src/androidTest) | +|default | Not covered in current plan | +endlegend + +@enduml diff --git a/uml/class-diagrams/components/debug/crashhandler.puml b/uml/class-diagrams/components/debug/crashhandler.puml new file mode 100644 index 000000000..b50e6ec85 --- /dev/null +++ b/uml/class-diagrams/components/debug/crashhandler.puml @@ -0,0 +1,72 @@ +@startuml +title CrashHandler – Structure + Test Coverage (class diagram only) + +skinparam packageStyle rectangle +skinparam classAttributeIconSize 0 +skinparam shadowing false +skinparam linetype ortho + +' ---- Coverage color scheme ---- +skinparam class { + BackgroundColor<> #E9FBE9 + BorderColor<> #2E7D32 + BackgroundColor<> #FFF3E0 + BorderColor<> #E65100 +} + +package "com.greybox.projectmesh.debug" as DBG { + + class CrashHandler <> { + - context: Context + - defaultHandler: UncaughtExceptionHandler + - activityToBeLaunched: Class + + uncaughtException(thread: Thread, throwable: Throwable) + - launchActivity(applicationContext: Context, activity: Class, exception: Throwable) + } + + ' Use a separate name (no dots) – avoids hierarchy errors + class CrashHandlerCompanion <> { + + init(applicationContext: Context, activityToBeLaunched: Class) + + getThrowableFromIntent(intent: Intent): Throwable? + } +} + +package "Android / Libs" as EXT { + interface UncaughtExceptionHandler + class Context + class Intent + class Log + class Gson +} + +' ---- Relationships ---- +UncaughtExceptionHandler <|.. CrashHandler +CrashHandler ..> Context +CrashHandler ..> Intent +CrashHandler ..> Log +CrashHandler ..> Gson + +' Document that this is the companion of CrashHandler +CrashHandlerCompanion .. CrashHandler : «companion» + +note right of CrashHandler +INSTRUMENTED (later): +• Launch activity with crash JSON +• Log error +• exitProcess(1) +• Fallback to default handler on failure +end note + +note right of CrashHandlerCompanion +JVM TESTS (now): +• Reflection: init(Context, Class) +• Reflection: getThrowableFromIntent(Intent): Throwable? +• Gson throwable message round-trip +end note + +legend left +|= Color |= Meaning | +|<#E9FBE9>| Covered by JVM unit tests | +|<#FFF3E0>| Requires instrumented tests (Android runtime) | +endlegend +@enduml diff --git a/uml/class-diagrams/components/debug/crashscreenactivity.puml b/uml/class-diagrams/components/debug/crashscreenactivity.puml new file mode 100644 index 000000000..7f06b85e1 --- /dev/null +++ b/uml/class-diagrams/components/debug/crashscreenactivity.puml @@ -0,0 +1,83 @@ +@startuml +title CrashScreenActivity – Structure + Test Coverage (class diagram only) + +skinparam packageStyle rectangle +skinparam classAttributeIconSize 0 +skinparam shadowing false +skinparam linetype ortho + +' ---- Coverage color scheme (same as other diagrams) ---- +skinparam class { + BackgroundColor<> #E9FBE9 + BorderColor<> #2E7D32 + BackgroundColor<> #FFF3E0 + BorderColor<> #E65100 +} + +package "com.greybox.projectmesh.debug" as DBG { + + ' Activity is UI + Android runtime → will be instrumented later + class CrashScreenActivity <> { + + onCreate(savedInstanceState: Bundle?) + + Crash() <> + } +} + +' ===== External Android / Compose pieces (simplified) ===== +package "AndroidX / Android / Compose" as EXT { + class AppCompatActivity + class Bundle + class Intent + class Color + class Modifier + class Column + class Text + class ComposeButton +} + +' Other project dependency +class CrashHandler + +' ===== Relationships ===== +AppCompatActivity <|-- CrashScreenActivity +CrashScreenActivity ..> Bundle +CrashScreenActivity ..> CrashHandler +CrashScreenActivity ..> Color +CrashScreenActivity ..> Modifier +CrashScreenActivity ..> Column +CrashScreenActivity ..> Text +CrashScreenActivity ..> ComposeButton + +note right of CrashScreenActivity +RUNTIME BEHAVIOR (instrumented later): +• onCreate() calls setContent { Crash() } +• Crash() composable: + - Shows "Crash! Please screenshot..." text + - Shows "Exit app" Button that calls finish() + - Uses CrashHandler.getThrowableFromIntent(intent) + and displays: + - Throwable.message + - stackTraceToString() + +JVM TESTS (CrashScreenActivityTest): +• Do NOT instantiate the Activity or Compose UI. +• Only verify the contract this Activity relies on: + - CrashHandler.getThrowableFromIntent(Intent): + - returns Throwable when JSON is valid + - returns null when JSON is invalid or missing + +Net effect: +• CrashScreenActivity UI itself is NOT JVM-tested. +• Its dependency contract with CrashHandler is covered + by JVM tests; UI & lifecycle will be covered by + future instrumented tests. +end note + +legend left +|= Color |= Meaning | +|<#E9FBE9>| Covered DIRECTLY by JVM unit tests | +|<#FFF3E0>| Intended for instrumented tests (Android runtime) | +|default | Currently untested | +endlegend + +@enduml diff --git a/uml/class-diagrams/components/extension/ContextExt.puml b/uml/class-diagrams/components/extension/ContextExt.puml new file mode 100644 index 000000000..34e872f3f --- /dev/null +++ b/uml/class-diagrams/components/extension/ContextExt.puml @@ -0,0 +1,35 @@ +@startuml +title ContextExt - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +class "ContextExtKt" as ContextExt { + + NEARBY_WIFI_PERMISSION_NAME: String + + hasNearbyWifiDevicesOrLocationPermission(context: Context): Boolean + + hasBluetoothConnectPermission(context: Context): Boolean + + hasStaApConcurrency(context: Context): Boolean + + deviceInfo(context: Context): String +} + +class Context +class WifiManager +class PackageManager +class DataStore +class Preferences + +ContextExt ..> Context +ContextExt ..> WifiManager +ContextExt ..> PackageManager +ContextExt ..> DataStore +ContextExt ..> Preferences + +note right of ContextExt +Tests focus on: +- permission helper results +- Wi-Fi concurrency capability gating +- device info string composition +end note + +@enduml diff --git a/uml/class-diagrams/components/extension/listExtension.puml b/uml/class-diagrams/components/extension/listExtension.puml new file mode 100644 index 000000000..020e8ae82 --- /dev/null +++ b/uml/class-diagrams/components/extension/listExtension.puml @@ -0,0 +1,21 @@ +@startuml + +class "List" { +} + +class "updateItem Extension" { + + updateItem(condition: (T) -> Boolean, function: (T) -> T): List +} + +"updateItem Extension" ..> "List" : extends +"updateItem Extension" ..> "List" : returns updated list + +note right of "updateItem Extension" +Extension function on List +- Finds first item matching condition +- Applies transformation function +- Returns original list if no match found +- Returns new updated list otherwise +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/extension/networkUtils.puml b/uml/class-diagrams/components/extension/networkUtils.puml new file mode 100644 index 000000000..d8f0464a2 --- /dev/null +++ b/uml/class-diagrams/components/extension/networkUtils.puml @@ -0,0 +1,22 @@ +@startuml + +class "DI" { +} + +class "AndroidVirtualNode" { + + address: InetAddress +} + +class "getLocalIpFromDI" { + + getLocalIpFromDI(di: DI): String +} + +"getLocalIpFromDI" ..> "DI" : uses +"getLocalIpFromDI" ..> "AndroidVirtualNode" : retrieves + +note right of "getLocalIpFromDI" +- Fetches AndroidVirtualNode from DI +- Returns node.address.hostAddress +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/extension/wifiListItem.puml b/uml/class-diagrams/components/extension/wifiListItem.puml new file mode 100644 index 000000000..fe8b2e1e7 --- /dev/null +++ b/uml/class-diagrams/components/extension/wifiListItem.puml @@ -0,0 +1,42 @@ +@startuml + +class "WifiListItem (Composable)" { + + WifiListItem( + wifiAddress: Int, + wifiEntry: VirtualNode.LastOriginatorMessage, + onClick: ((String) -> Unit)? + ) +} + +class "VirtualNode.LastOriginatorMessage" { + + originatorMessage + + hopCount: Int +} + +class "GlobalApp" { + + GlobalUserRepo +} + +class "UserRepository" { + + getUserByIp(ip: String): User? +} + +class "addressToDotNotation" { + + addressToDotNotation(Int): String +} + +"WifiListItem (Composable)" ..> "VirtualNode.LastOriginatorMessage" : uses +"WifiListItem (Composable)" ..> "GlobalApp" : accesses +"WifiListItem (Composable)" ..> "UserRepository" : fetches user +"WifiListItem (Composable)" ..> "addressToDotNotation" : converts IP + +note right of "WifiListItem (Composable)" +- Displays a WiFi node item +- Converts IP to dot notation +- Fetches user name using repository (blocking call) +- Shows device name or fallback +- Displays mesh status (ping + hops) +- Optional clickable behavior +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/messaging/ContentResolverExtension.puml b/uml/class-diagrams/components/messaging/ContentResolverExtension.puml new file mode 100644 index 000000000..83756e30b --- /dev/null +++ b/uml/class-diagrams/components/messaging/ContentResolverExtension.puml @@ -0,0 +1,28 @@ +@startuml +package com.greybox.projectmesh.extension { + class UriNameAndSize { + +name: String? + +size: Long + } + + class ContentResolver <> { + +query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? + } + + class Cursor <> { + +moveToFirst(): Boolean + +getColumnIndex(columnName: String): Int + +isNull(columnIndex: Int): Boolean + +getString(columnIndex: Int): String? + +close() + } + + class ContentResolverExtension <<(E,#FF7700)>> { + +getUriNameAndSize(uri: Uri): UriNameAndSize + } +} + +ContentResolverExtension ..> ContentResolver : extension +ContentResolverExtension ..> Cursor : uses +ContentResolverExtension --> UriNameAndSize : returns +@enduml diff --git a/uml/class-diagrams/components/messaging/Conversation.puml b/uml/class-diagrams/components/messaging/Conversation.puml new file mode 100644 index 000000000..1f47e6885 --- /dev/null +++ b/uml/class-diagrams/components/messaging/Conversation.puml @@ -0,0 +1,20 @@ +@startuml Conversation +skinparam classAttributeIconSize 0 + +class Conversation <> { + +id: String + +userUuid: String + +userName: String + +userAddress: String? + +lastMessage: String? + +lastMessageTime: Long + +unreadCount: Int = 0 + +isOnline: Boolean = false +} + +note right of Conversation +@Entity(tableName = "conversations") +@PrimaryKey id +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/ConversationDao.puml b/uml/class-diagrams/components/messaging/ConversationDao.puml new file mode 100644 index 000000000..6daa5b72e --- /dev/null +++ b/uml/class-diagrams/components/messaging/ConversationDao.puml @@ -0,0 +1,18 @@ +@startuml ConversationDao +skinparam classAttributeIconSize 0 + +interface ConversationDao <> { + +getAllConversationsFlow(): Flow> + +getConversationById(conversationId: String): Conversation? + +getConversationByUserUuid(userUuid: String): Conversation? + +insertConversation(conversation: Conversation): Unit + +updateConversation(conversation: Conversation): Unit + +updateUserConnectionStatus(userUuid: String, isOnline: Boolean, userAddress: String?): Unit + +updateLastMessage(conversationId: String, lastMessage: String, timestamp: Long): Unit + +incrementUnreadCount(conversationId: String): Unit + +clearUnreadCount(conversationId: String): Unit +} + +ConversationDao ..> Conversation + +@enduml diff --git a/uml/class-diagrams/components/messaging/ConversationRepository.puml b/uml/class-diagrams/components/messaging/ConversationRepository.puml new file mode 100644 index 000000000..521cbf78d --- /dev/null +++ b/uml/class-diagrams/components/messaging/ConversationRepository.puml @@ -0,0 +1,37 @@ +@startuml ConversationRepository +skinparam classAttributeIconSize 0 + +class ConversationRepository { + - conversationDao : ConversationDao + + getAllConversations() : Flow> + + getConversationById(conversationId: String) : Conversation? + + getOrCreateConversation(localUuid: String, remoteUser: UserEntity) : Conversation + + updateWithMessage(conversationId: String, message: Message) + + markAsRead(conversationId: String) + + updateUserStatus(userUuid: String, isOnline: Boolean, userAddress: String?) +} + +interface ConversationDao { + + getAllConversationsFlow() : Flow> + + getConversationById(conversationId: String) : Conversation? + + insertConversation(conversation: Conversation) + + updateLastMessage(conversationId: String, lastMessage: String, timestamp: Long) + + incrementUnreadCount(conversationId: String) + + clearUnreadCount(conversationId: String) + + updateUserConnectionStatus(userUuid: String, isOnline: Boolean, userAddress: String?) +} + +class Conversation +class Message +class UserEntity + +class ConversationUtils { + {static} + createConversationId(localUuid: String, remoteUuid: String) : String +} + +ConversationRepository --> ConversationDao +ConversationRepository ..> ConversationUtils : creates conversationId +ConversationRepository ..> UserEntity : remote user info +ConversationRepository ..> Conversation : creates/returns +ConversationRepository ..> Message : updates with last message +@enduml diff --git a/uml/class-diagrams/components/messaging/ConversationUtils.puml b/uml/class-diagrams/components/messaging/ConversationUtils.puml new file mode 100644 index 000000000..694c47533 --- /dev/null +++ b/uml/class-diagrams/components/messaging/ConversationUtils.puml @@ -0,0 +1,18 @@ +@startuml ConversationUtils + +package "com.greybox.projectmesh.messaging.utils" { + + object ConversationUtils { + + createConversationId(uuid1: String, uuid2: String): String + } +} + +note right of ConversationUtils + - Handles ordering of UUID pairs + - Applies special cases: + * "test-device-uuid" + * "offline-test-device-uuid" + - Ensures stable conversation IDs +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/FileEncoder.puml b/uml/class-diagrams/components/messaging/FileEncoder.puml new file mode 100644 index 000000000..ca523053e --- /dev/null +++ b/uml/class-diagrams/components/messaging/FileEncoder.puml @@ -0,0 +1,45 @@ +@startuml FileEncoder + +package "com.greybox.projectmesh.messaging.data.entities" { + + class FileEncoder { + + + encodebase64(ctxt: Context, inputuri: Uri): String + + encodeBytesBase64(bytes: ByteArray): String + + decodeBase64(inputbase64: String, output: File): File + + sendImage(imageURI: Uri, tgtaddress: InetAddress, tgtport: Int, appctxt: Context): Boolean + } +} + +package "android.content" { + class Context +} + +package "android.net" { + class Uri +} + +package "java.io" { + class File +} + +package "java.net" { + class InetAddress +} + +FileEncoder --> Context +FileEncoder --> Uri +FileEncoder --> File +FileEncoder --> InetAddress + +note right of FileEncoder + - Encodes file content to Base64 + - Decodes Base64 back into a File + - Sends image data over HTTP + - Used by FileEncoderTest to verify: + * correct Base64 encoding + * null handling + * file overwrite behavior +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/InputStreamCounter.puml b/uml/class-diagrams/components/messaging/InputStreamCounter.puml new file mode 100644 index 000000000..514b607a1 --- /dev/null +++ b/uml/class-diagrams/components/messaging/InputStreamCounter.puml @@ -0,0 +1,27 @@ +@startuml InputStreamCounter + +package "java.io" { + class InputStream + class FilterInputStream { + - in: InputStream + + read(): int + + read(b: byte[], off: int, len: int): int + + close(): void + } + FilterInputStream --> InputStream +} + +package "com.greybox.projectmesh.server" { + class InputStreamCounter extends FilterInputStream { + + bytesRead: Int + + closed: Boolean + + + read(): Int + + read(b: ByteArray, off: Int, len: Int): Int + + close(): Void + } +} + +InputStreamCounter --> InputStream : wraps + +@enduml diff --git a/uml/class-diagrams/components/messaging/JSONSchema.puml b/uml/class-diagrams/components/messaging/JSONSchema.puml new file mode 100644 index 000000000..8d5875789 --- /dev/null +++ b/uml/class-diagrams/components/messaging/JSONSchema.puml @@ -0,0 +1,13 @@ +@startuml JSONSchema +skinparam classAttributeIconSize 0 + +class JSONSchema { + -schemaString: String + +schemaValidation(json: String): Boolean + -validate(json: JSONObject, schema: JSONObject): Unit +} + +JSONSchema ..> JSONObject +JSONSchema ..> JSONException + +@enduml diff --git a/uml/class-diagrams/components/messaging/ListExtension.puml b/uml/class-diagrams/components/messaging/ListExtension.puml new file mode 100644 index 000000000..9fd60c6b6 --- /dev/null +++ b/uml/class-diagrams/components/messaging/ListExtension.puml @@ -0,0 +1,24 @@ +@startuml ListExtension + +package "kotlin.collections" { + interface List { + + indexOfFirst(predicate): Int + + toMutableList(): MutableList + } + + interface MutableList extends List { + + set(index: Int, value) + + toList(): List + } +} + +package "com.greybox.projectmesh.extension" { + class ListExtension { + <> + + updateItem(condition, function): List + } +} + +ListExtension ..> List : extends functionality + +@enduml diff --git a/uml/class-diagrams/components/messaging/Logger.puml b/uml/class-diagrams/components/messaging/Logger.puml new file mode 100644 index 000000000..860b6bc0d --- /dev/null +++ b/uml/class-diagrams/components/messaging/Logger.puml @@ -0,0 +1,34 @@ +@startuml Logger + +package "com.greybox.projectmesh.utils" { + + object Logger { + + - LOGGING_ENABLED: Boolean + + TAG_PREFIX: String + + + buildTag(tag: String): String + + buildCriticalTag(tag: String): String + + + d(tag: String, message: String) + + i(tag: String, message: String) + + w(tag: String, message: String) + + e(tag: String, message: String, throwable: Throwable) + + critical(tag: String, message: String, throwable: Throwable) + } +} + +package "android.util" { + class Log +} + +Logger --> Log : uses + +note right of Logger + - Centralized logging utility + - Builds standardized tags + - Supports debug/info/warn/error logging + - Special critical logging always visible +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/Message.puml b/uml/class-diagrams/components/messaging/Message.puml new file mode 100644 index 000000000..1e133b428 --- /dev/null +++ b/uml/class-diagrams/components/messaging/Message.puml @@ -0,0 +1,32 @@ +@startuml Message +skinparam classAttributeIconSize 0 + +class URIConverter { + +convfromURI(theuri: URI?): String? + +convtoURI(uristring: String?): URI? +} + +class URISerializable <>> { + +descriptor: SerialDescriptor + +serialize(enc: Encoder, vals: URI): Unit + +deserialize(dec: Decoder): URI +} + +class Message <> { + +id: Int + +dateReceived: Long + +content: String + +sender: String + +chat: String + +file: URI? = null +} + +Message ..> URIConverter : @TypeConverters +Message ..> URISerializable : @Serializable(with) + +note right of Message +@Entity(tableName = "message") +@PrimaryKey(autoGenerate = true) id +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageDao.puml b/uml/class-diagrams/components/messaging/MessageDao.puml new file mode 100644 index 000000000..21910fd7d --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageDao.puml @@ -0,0 +1,18 @@ +@startuml MessageDao +skinparam classAttributeIconSize 0 + +interface MessageDao <> { + +getAll(): List + +getAllFlow(): Flow> + +getChatMessagesFlow(chat: String): Flow> + +clearTable(): Unit + +getChatMessagesFlowMultipleNames(chatNames: List): Flow> + +getChatMessagesSync(chat: String): List + +addMessage(m: Message): Unit + +delete(m: Message): Unit + +deleteAll(messages: List): Unit +} + +MessageDao ..> Message + +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageMigrationUtils.puml b/uml/class-diagrams/components/messaging/MessageMigrationUtils.puml new file mode 100644 index 000000000..9195084d5 --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageMigrationUtils.puml @@ -0,0 +1,42 @@ +@startuml MessageMigrationUtils + +package "org.kodein.di" { + interface DIAware + class DI +} + +package "com.greybox.projectmesh.db" { + class MeshDatabase +} + +package "com.greybox.projectmesh" { + class GlobalApp +} + +package "com.greybox.projectmesh.testing" { + class TestDeviceService +} + +package "android.util" { + class Log +} + +package "com.greybox.projectmesh.messaging.utils" { + + class MessageMigrationUtils implements DIAware { + + - db: MeshDatabase + + + MessageMigrationUtils(di: DI) + + migrateMessagesToChatIds() + + createConversationId(uuid1: String, uuid2: String): String + } +} + +MessageMigrationUtils --> DI : injected via DI +MessageMigrationUtils --> MeshDatabase : uses +MessageMigrationUtils --> GlobalApp : reads user/prefs +MessageMigrationUtils --> TestDeviceService : special names +MessageMigrationUtils --> Log : logging + +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageNetworkHandler.puml b/uml/class-diagrams/components/messaging/MessageNetworkHandler.puml new file mode 100644 index 000000000..48f259d56 --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageNetworkHandler.puml @@ -0,0 +1,29 @@ +@startuml MessageNetworkHandler +skinparam classAttributeIconSize 0 + +class MessageNetworkHandler { + -httpClient: OkHttpClient + -localVirtualAddr: InetAddress + -di: DI + -scope: CoroutineScope + -conversationRepository: ConversationRepository + -settingsPrefs: SharedPreferences + +sendChatMessage(address: InetAddress, time: Long, message: String, file: URI): Unit +} + +class "MessageNetworkHandler.Companion" as MessageNetworkHandlerCompanion { + +handleIncomingMessage(chatMessage: String, time: Long, senderIp: InetAddress, incomingfile: URI): Message + -showMessageNotification(conversation: Conversation, message: Message, senderIp: InetAddress): Unit +} + +MessageNetworkHandler --> OkHttpClient +MessageNetworkHandler --> ConversationRepository +MessageNetworkHandler --> SharedPreferences +MessageNetworkHandler --> AppServer : uses DEFAULT_PORT +MessageNetworkHandlerCompanion --> Message +MessageNetworkHandlerCompanion --> Conversation +MessageNetworkHandlerCompanion --> GlobalApp +MessageNetworkHandlerCompanion --> TestDeviceService +MessageNetworkHandlerCompanion --> ConversationUtils + +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageRepository.puml b/uml/class-diagrams/components/messaging/MessageRepository.puml new file mode 100644 index 000000000..a5f858f09 --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageRepository.puml @@ -0,0 +1,23 @@ +@startuml MessageRepository +skinparam classAttributeIconSize 0 + +class MessageRepository { + - messageDao : MessageDao + + getChatMessages(chatId: String) : Flow> + + addMessage(message: Message) + + getAllMessages() : Flow> + + clearMessages() +} + +interface MessageDao { + + getChatMessagesFlow(chat: String) : Flow> + + getAllFlow() : Flow> + + addMessage(m: Message) + + clearTable() +} + +class Message + +MessageRepository --> MessageDao +MessageRepository ..> Message +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageService.puml b/uml/class-diagrams/components/messaging/MessageService.puml new file mode 100644 index 000000000..83289797f --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageService.puml @@ -0,0 +1,22 @@ +@startuml MessageService +skinparam classAttributeIconSize 0 + +class MessageService { + -di: DI + -messageNetworkHandler: MessageNetworkHandler + -messageRepository: MessageRepository + -conversationRepository: ConversationRepository + -userRepository: UserRepository + -settingsPrefs: SharedPreferences + +sendMessage(address: InetAddress, message: Message): Unit + -updateConversationWithMessage(address: InetAddress, message: Message): Unit +} + +MessageService --> MessageNetworkHandler +MessageService --> MessageRepository +MessageService --> ConversationRepository +MessageService --> UserRepository +MessageService --> SharedPreferences +MessageService --> Message + +@enduml diff --git a/uml/class-diagrams/components/messaging/MessageUtils.puml b/uml/class-diagrams/components/messaging/MessageUtils.puml new file mode 100644 index 000000000..1e309e8a9 --- /dev/null +++ b/uml/class-diagrams/components/messaging/MessageUtils.puml @@ -0,0 +1,24 @@ +@startuml MessageUtils + +package "com.greybox.projectmesh.messaging.utils" { + + object MessageUtils { + + + formatTimestamp(timestamp: Long): String + + generateChatId(sender: String, receiver: String): String + } +} + +package "java.text" { + class SimpleDateFormat +} + +MessageUtils --> SimpleDateFormat : uses + +note right of MessageUtils + - Formats timestamps as "HH:mm" + - Generates consistent chat IDs + - Sorting ensures order independence +end note + +@enduml diff --git a/uml/class-diagrams/components/messaging/messagingModels.puml b/uml/class-diagrams/components/messaging/messagingModels.puml new file mode 100644 index 000000000..f467393df --- /dev/null +++ b/uml/class-diagrams/components/messaging/messagingModels.puml @@ -0,0 +1,39 @@ +@startuml + +class ChatScreenModel { + + deviceName: String? + + virtualAddress: InetAddress + + allChatMessages: List + + offlineWarning: String? +} + +class ConversationsHomeScreenModel { + + isLoading: Boolean + + conversations: List + + error: String? +} + +class Message +class Conversation +class InetAddress + +ChatScreenModel --> Message : contains +ChatScreenModel --> InetAddress : uses + +ConversationsHomeScreenModel --> Conversation : contains + +note right of ChatScreenModel +Represents state of chat screen +- Device info (name + IP) +- List of messages +- Offline warning handling +end note + +note right of ConversationsHomeScreenModel +Represents state of conversations list screen +- Loading state +- List of conversations +- Error handling +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/messaging/messagingScreens.puml b/uml/class-diagrams/components/messaging/messagingScreens.puml new file mode 100644 index 000000000..b813cdb5c --- /dev/null +++ b/uml/class-diagrams/components/messaging/messagingScreens.puml @@ -0,0 +1,130 @@ +@startuml + +class "ChatNodeListScreen" <> { + + onNodeSelected: (String) -> Unit +} + +class "ChatScreen" <> { + + virtualAddress: InetAddress + + userName: String? + + isOffline: Boolean + + onClickButton: () -> Unit +} + +class "UserStatusBar" <> { + + userName: String + + isOnline: Boolean + + userAddress: String +} + +class "DisplayAllMessages" <> { + + uiState: ChatScreenModel + + onClickButton: () -> Unit +} + +class "MessageBubble" <> { + + chatMessage: Message + + sentBySelf: Boolean + + messageContent: @Composable + + sender: String + + modifier: Modifier +} + +class "ConversationsHomeScreen" <> { + + onConversationSelected: (String) -> Unit +} + +class "ConversationsList" <> { + + conversations: List + + onConversationClick: (Conversation) -> Unit +} + +class "ConversationItem" <> { + + conversation: Conversation + + onClick: () -> Unit +} + +class "EmptyConversationsView" <> +class "ErrorView" <> { + + errorMessage: String + + onRetry: () -> Unit +} + +class "NetworkScreenViewModel" +class "NetworkScreenModel" +class "ChatScreenViewModel" +class "ChatScreenModel" +class "ConversationsHomeScreenViewModel" +class "ConversationsHomeScreenModel" +class "Conversation" +class "Message" +class "WifiListItem" <> +class "AppServer" +class "DeviceStatusManager" +class "GlobalApp" +class "MessageUtils" +class "LongPressCopyableText" <> +class "ViewModelFactory" +class "InetAddress" + +ChatNodeListScreen ..> "NetworkScreenViewModel" : uses +ChatNodeListScreen ..> "NetworkScreenModel" : collects state +ChatNodeListScreen ..> "WifiListItem" : displays nodes +ChatNodeListScreen ..> "ViewModelFactory" : creates VM + +ChatScreen ..> "ChatScreenViewModel" : uses +ChatScreen ..> "ChatScreenModel" : collects state +ChatScreen ..> "UserStatusBar" : displays +ChatScreen ..> "DisplayAllMessages" : displays +ChatScreen ..> "AppServer" : sends files +ChatScreen ..> "DeviceStatusManager" : observes status +ChatScreen ..> "GlobalApp" : fetches user info +ChatScreen ..> "ViewModelFactory" : creates VM +ChatScreen ..> "InetAddress" : uses + +DisplayAllMessages ..> "ChatScreenModel" : reads +DisplayAllMessages ..> "MessageBubble" : displays +DisplayAllMessages ..> "Message" : iterates +DisplayAllMessages ..> "GlobalApp" : fetches sender name +DisplayAllMessages ..> "LongPressCopyableText" : uses + +MessageBubble ..> "Message" : displays + +ConversationsHomeScreen ..> "ConversationsHomeScreenViewModel" : uses +ConversationsHomeScreen ..> "ConversationsHomeScreenModel" : collects state +ConversationsHomeScreen ..> "ConversationsList" : displays +ConversationsHomeScreen ..> "EmptyConversationsView" : displays +ConversationsHomeScreen ..> "ErrorView" : displays +ConversationsHomeScreen ..> "ViewModelFactory" : creates VM + +ConversationsList ..> "ConversationItem" : renders list +ConversationsList ..> "Conversation" : uses + +ConversationItem ..> "Conversation" : displays +ConversationItem ..> "MessageUtils" : formats timestamp + +note right of "ChatNodeListScreen" +Shows available chat nodes +using NetworkScreenViewModel +and WifiListItem +end note + +note right of "ChatScreen" +Main chat UI +- resolves user info +- tracks online/offline state +- sends text messages +- sends files +- displays conversation history +end note + +note right of "ConversationsHomeScreen" +Conversation home screen +- loading state +- error state +- empty state +- list of conversations +- opens selected conversation +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/messaging/messagingVM.puml b/uml/class-diagrams/components/messaging/messagingVM.puml new file mode 100644 index 000000000..9fed6be4f --- /dev/null +++ b/uml/class-diagrams/components/messaging/messagingVM.puml @@ -0,0 +1,100 @@ +@startuml + +class ChatScreenViewModel { + - virtualAddress: InetAddress + - ipStr: String + - passedConversationId: String? + - userEntity: UserEntity? + - deviceName: String + - sharedPrefs: SharedPreferences + - localUuid: String + - userUuid: String + - savedConversationId: String? + - conversationId: String + - chatName: String + - addressDotNotation: String + - conversationRepository: ConversationRepository + - _uiState: MutableStateFlow + + uiState: Flow + - db: MeshDatabase + - appServer: AppServer + - _deviceOnlineStatus: MutableStateFlow + + deviceOnlineStatus: StateFlow + - markConversationAsRead(): Unit + + sendChatMessage(virtualAddress: InetAddress, message: String, file: URI?): Unit + + addOutgoingTransfer(fileUri: Uri, toAddress: InetAddress): OutgoingTransferInfo +} + +class ConversationsHomeScreenViewModel { + - _uiState: MutableStateFlow + + uiState: Flow + - conversationRepository: ConversationRepository + - sharedPrefs: SharedPreferences + - updateConversationStatuses(deviceStatusMap: Map): Unit + - loadConversations(): Unit + + refreshConversations(): Unit + + markConversationAsRead(conversationId: String): Unit +} + +class ChatScreenModel +class ConversationsHomeScreenModel +class ConversationRepository +class MeshDatabase +class AppServer +class OutgoingTransferInfo +class DeviceStatusManager +class ConversationUtils +class TestDeviceService +class GlobalApp +class UserEntity +class SharedPreferences +class SavedStateHandle +class Message +class InetAddress +class Uri +class URI + +ChatScreenViewModel --> ChatScreenModel : manages state +ChatScreenViewModel --> ConversationRepository : uses +ChatScreenViewModel --> MeshDatabase : uses +ChatScreenViewModel --> AppServer : sends messages/files +ChatScreenViewModel --> OutgoingTransferInfo : returns +ChatScreenViewModel --> DeviceStatusManager : monitors status +ChatScreenViewModel --> ConversationUtils : creates conversation id +ChatScreenViewModel --> TestDeviceService : test device logic +ChatScreenViewModel --> GlobalApp : fetches user info +ChatScreenViewModel --> UserEntity : uses +ChatScreenViewModel --> SharedPreferences : reads local UUID +ChatScreenViewModel --> SavedStateHandle : reads nav args +ChatScreenViewModel --> Message : creates/stores +ChatScreenViewModel --> InetAddress : uses +ChatScreenViewModel --> Uri : uses +ChatScreenViewModel --> URI : uses + +ConversationsHomeScreenViewModel --> ConversationsHomeScreenModel : manages state +ConversationsHomeScreenViewModel --> ConversationRepository : uses +ConversationsHomeScreenViewModel --> SharedPreferences : reads local UUID +ConversationsHomeScreenViewModel --> DeviceStatusManager : monitors status + +note right of ChatScreenViewModel +Handles chat screen business logic +- resolves conversation identity +- loads messages from database +- observes live message flow +- tracks online/offline status +- stores outgoing messages locally +- attempts network delivery +- updates offline warning state +- supports outgoing file transfer +end note + +note right of ConversationsHomeScreenViewModel +Handles conversation list state +- loads all conversations +- filters out local user +- updates online/offline status +- refreshes conversation list +- marks conversations as read +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/navigation/bottomnav.puml b/uml/class-diagrams/components/navigation/bottomnav.puml new file mode 100644 index 000000000..fcc43ff92 --- /dev/null +++ b/uml/class-diagrams/components/navigation/bottomnav.puml @@ -0,0 +1,85 @@ +@startuml +title BottomNavItem.kt + BottomNavItemTest (Structure + JVM Test Coverage) + +skinparam packageStyle rectangle +skinparam shadowing false +skinparam classAttributeIconSize 0 +skinparam linetype ortho + +' Color scheme: prod data vs JVM-tested +skinparam class { + BackgroundColor<> #E0F7FA + BorderColor<> #00838F + + BackgroundColor<> #FFF3E0 + BorderColor<> #F57C00 +} + +' =============================== +' Production code (navigation) +' =============================== +package "com.greybox.projectmesh.navigation" { + + abstract class BottomNavItem <> { + + route: String + + title: String + + icon: ImageVector + } + + ' Sealed subclasses as objects + class Home <> extends BottomNavItem + class Network <> extends BottomNavItem + class Send <> extends BottomNavItem + class Receive <> extends BottomNavItem + class Log <> extends BottomNavItem + class Settings <> extends BottomNavItem + class Chat <> extends BottomNavItem + + class ImageVector + + BottomNavItem <|-- Home + BottomNavItem <|-- Network + BottomNavItem <|-- Send + BottomNavItem <|-- Receive + BottomNavItem <|-- Log + BottomNavItem <|-- Settings + BottomNavItem <|-- Chat + + Home --> ImageVector : icon + Network --> ImageVector : icon + Send --> ImageVector : icon + Receive --> ImageVector : icon + Log --> ImageVector : icon + Settings --> ImageVector : icon + Chat --> ImageVector : icon +} + +' =============================== +' JVM unit test +' =============================== +package "Unit tests (src/test)" { + + class BottomNavItemTest <> { + + allItems: List + + allItems_haveExpectedRoutesAndTitles() + + allItems_haveNonNullIcons() + + routes_areUniqueAcrossAllItems() + + sealedHierarchy_containsExactlyExpectedItems() + } + + BottomNavItemTest ..> BottomNavItem : verifies\nroutes/titles/icons + BottomNavItemTest ..> Home + BottomNavItemTest ..> Network + BottomNavItemTest ..> Sendπππ + BottomNavItemTest ..> Receive + BottomNavItemTest ..> Log + BottomNavItemTest ..> Settings + BottomNavItemTest ..> Chat +} + +legend right + <&rectangle> «DATA» = navigation data model (prod code) + <&rectangle> «JVM_TEST» = covered by JVM unit test +endlegend + +@enduml diff --git a/uml/class-diagrams/components/navigation/navitem.puml b/uml/class-diagrams/components/navigation/navitem.puml new file mode 100644 index 000000000..6b9b8f491 --- /dev/null +++ b/uml/class-diagrams/components/navigation/navitem.puml @@ -0,0 +1,66 @@ +@startuml +title NavigationItem.kt + NavigationItemTest (Structure + JVM Test Coverage) + +skinparam packageStyle rectangle +skinparam shadowing false +skinparam classAttributeIconSize 0 +skinparam linetype ortho + +' Color scheme: data vs JVM-tested +skinparam class { + BackgroundColor<> #E0F7FA + BorderColor<> #00838F + + BackgroundColor<> #FFF3E0 + BorderColor<> #F57C00 +} + +' =============================== +' Production code (navigation) +' =============================== +package "com.greybox.projectmesh.navigation" { + + class NavigationItem <> { + - route: String + - label: String + - icon: ImageVector + + copy(...) + + equals(...) + + hashCode() + } + + class ImageVector + + ' Internal name without dot; label shows real type + class ImageVectorBuilder as "ImageVector.Builder" { + + Builder(defaultWidth: Dp, + defaultHeight: Dp, + viewportWidth: Float, + viewportHeight: Float) + + build(): ImageVector + } + + NavigationItem --> ImageVector : uses + ImageVectorBuilder --> ImageVector : builds +} + +' =============================== +' JVM unit test +' =============================== +package "Unit tests (src/test)" { + + class NavigationItemTest <> { + + dummyIcon: ImageVector + + navigationItem_copyEqualsHashCodeCorrect() + } + + NavigationItemTest ..> NavigationItem : verifies\ncopy/equals/hashCode + NavigationItemTest ..> ImageVectorBuilder : builds dummyIcon +} + +legend right + <&rectangle> «DATA» = app data model (prod code) + <&rectangle> «JVM_TEST» = covered by JVM unit test +endlegend + +@enduml diff --git a/uml/class-diagrams/components/server/appserver.puml b/uml/class-diagrams/components/server/appserver.puml new file mode 100644 index 000000000..0d3647411 --- /dev/null +++ b/uml/class-diagrams/components/server/appserver.puml @@ -0,0 +1,53 @@ +@startuml +title AppServer - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +class AppServer { + -appContext: Context + -httpClient: OkHttpClient + -mLogger: MNetLogger + -localVirtualAddr: InetAddress + -receiveDir: File + -json: Json + -db: MeshDatabase + -userRepository: UserRepository + +serve(session: IHTTPSession): Response + +addOutgoingTransfer(uri: Uri, toNode: InetAddress, toPort: Int): OutgoingTransferInfo + +sendChatMessageWithStatus(address: InetAddress, time: Long, message: String, f: URI?): Boolean + +checkDeviceReachable(remoteAddr: InetAddress, port: Int): Boolean +} + +class OutgoingTransferInfo <> +class IncomingTransferInfo <> +class MeshDatabase +class MessageDao +class UserRepository +class OkHttpClient +class JSONSchema +class MessageNetworkHandler +class NotificationHelper +class SharedPreferences + +AppServer --> OkHttpClient +AppServer --> MeshDatabase +AppServer --> MessageDao +AppServer --> UserRepository +AppServer --> JSONSchema +AppServer --> MessageNetworkHandler +AppServer --> NotificationHelper +AppServer --> SharedPreferences +AppServer --> OutgoingTransferInfo +AppServer --> IncomingTransferInfo + +note right of AppServer +Key behaviors covered by tests: +- route handling for /ping, /myinfo, /updateUserInfo, /chat +- outgoing transfer tracking +- reachability checks +- chat send success/failure paths +end note + +@enduml diff --git a/uml/class-diagrams/components/testing/TestDeviceEntry.puml b/uml/class-diagrams/components/testing/TestDeviceEntry.puml new file mode 100644 index 000000000..dd91238e8 --- /dev/null +++ b/uml/class-diagrams/components/testing/TestDeviceEntry.puml @@ -0,0 +1,33 @@ +@startuml +title TestDeviceEntry - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +class TestDeviceEntry { + + createTestEntry(): Pair +} + +class TestDeviceService +class TestMNetLogger +class TestVirtualRouter +class MmcpOriginatorMessage +class "VirtualNode.LastOriginatorMessage" as LastOriginatorMessage +class VirtualNodeDatagramSocket + +TestDeviceEntry --> TestDeviceService +TestDeviceEntry --> TestMNetLogger +TestDeviceEntry --> TestVirtualRouter +TestDeviceEntry --> MmcpOriginatorMessage +TestDeviceEntry --> LastOriginatorMessage +TestDeviceEntry --> VirtualNodeDatagramSocket + +note right of TestDeviceEntry +Tests verify: +- generated address metadata +- originator ping and hop values +- bound socket availability for the fake device entry +end note + +@enduml diff --git a/uml/class-diagrams/components/testing/TestDeviceService.puml b/uml/class-diagrams/components/testing/TestDeviceService.puml new file mode 100644 index 000000000..f6e2a41c4 --- /dev/null +++ b/uml/class-diagrams/components/testing/TestDeviceService.puml @@ -0,0 +1,42 @@ +@startuml +title TestDeviceService - Class Diagram + +skinparam classAttributeIconSize 0 +hide empty members +set namespaceSeparator none + +class TestDeviceService { + + TEST_DEVICE_IP: String + + TEST_DEVICE_NAME: String + + TEST_DEVICE_IP_OFFLINE: String + + TEST_DEVICE_NAME_OFFLINE: String + + initialize(): Unit + + initializeOfflineDevice(): Unit + + isOnlineTestDevice(address: InetAddress): Boolean + + isOfflineTestDevice(address: InetAddress): Boolean + + getTestDeviceAddress(): InetAddress + + isTestDevice(address: InetAddress): Boolean + + createEchoResponse(originalMessage: Message): Message +} + +class GlobalUserRepo +class UserRepository +class UserEntity +class Message +class InetAddress + +TestDeviceService --> GlobalUserRepo +TestDeviceService --> UserRepository +TestDeviceService --> UserEntity +TestDeviceService --> Message +TestDeviceService --> InetAddress + +note right of TestDeviceService +Tests cover: +- first-time initialization +- update-vs-insert behavior +- idempotency across repeated init calls +- helper IP classification and echo message generation +end note + +@enduml diff --git a/uml/class-diagrams/components/ui.theme/ui_theme1.puml b/uml/class-diagrams/components/ui.theme/ui_theme1.puml new file mode 100644 index 000000000..c7c136a5a --- /dev/null +++ b/uml/class-diagrams/components/ui.theme/ui_theme1.puml @@ -0,0 +1,85 @@ +@startuml +title Theme Layer (Color.kt + Theme.kt + Type.kt) • Structure + JVM Test Coverage + +skinparam packageStyle rectangle +skinparam classAttributeIconSize 0 +skinparam shadowing false +skinparam linetype ortho + +skinparam class { + BackgroundColor<> #E0F7FA + BorderColor<> #00838F + + BackgroundColor<> #FFF3E0 + BorderColor<> #EF6C00 +} + +' =============================== +' PACKAGES / FILE SEPARATION +' =============================== + +package "com.greybox.projectmesh.ui.theme\n(Color.kt)" <> { + + class ColorConstants { + +Purple80: Color + +PurpleGrey80: Color + +Pink80: Color + +Purple40: Color + +PurpleGrey40: Color + +Pink40: Color + } +} + +package "com.greybox.projectmesh.ui.theme\n(Theme.kt)" <> { + + enum AppTheme { + SYSTEM + LIGHT + DARK + } + + class ProjectMeshTheme <> { + +invoke(appTheme: AppTheme, content: Composable) + } +} + +package "com.greybox.projectmesh.ui.theme\n(Type.kt)" <> { + + class TypographySet { + +bodyLarge: TextStyle + } +} + +' =============================== +' TEST FILE (JVM ONLY) +' =============================== + +package "ThemeLayerTest.kt\n(JVM Unit Test)" <> { + + class ThemeLayerTest { + +colors_haveExpectedArgbValues() + +appTheme_containsExpectedValuesInOrder() + +typography_bodyLarge_hasExpectedDefaults() + } +} + +' =============================== +' RELATIONSHIPS +' =============================== + +ThemeLayerTest ..> ColorConstants : verifies ARGB\nvalues +ThemeLayerTest ..> AppTheme : verifies enum\norder + names +ThemeLayerTest ..> TypographySet : verifies\nbodyLarge style + +' Composable not JVM testable +ProjectMeshTheme -[dotted]-> ColorConstants : uses +ProjectMeshTheme -[dotted]-> TypographySet : uses + +note bottom +Legend: + - Blue boxes = Source files (Color.kt, Theme.kt, Type.kt) + - Orange boxes = JVM tests (ThemeLayerTest.kt) + - Dotted links = Not testable on JVM (requires instrumented tests) +end note + +@enduml diff --git a/uml/class-diagrams/components/user/UserDao.puml b/uml/class-diagrams/components/user/UserDao.puml new file mode 100644 index 000000000..a0ab47ff7 --- /dev/null +++ b/uml/class-diagrams/components/user/UserDao.puml @@ -0,0 +1,16 @@ +@startuml UserDao +skinparam classAttributeIconSize 0 + +interface UserDao <> { + +getUserByUuid(uuid: String): UserEntity? + +insertUser(user: UserEntity): Unit + +updateUser(user: UserEntity): Unit + +hasWithID(uuid: String): Boolean + +getUserByIp(ip: String): UserEntity? + +getAllConnectedUsers(): List + +getAllUsers(): List +} + +UserDao ..> UserEntity + +@enduml diff --git a/uml/class-diagrams/components/user/UserEntity.puml b/uml/class-diagrams/components/user/UserEntity.puml new file mode 100644 index 000000000..6c9293698 --- /dev/null +++ b/uml/class-diagrams/components/user/UserEntity.puml @@ -0,0 +1,16 @@ +@startuml UserEntity +skinparam classAttributeIconSize 0 + +class UserEntity <> { + +uuid: String + +name: String + +address: String? = null + +lastSeen: Long? = null +} + +note right of UserEntity +@Entity(tableName = "users") +@PrimaryKey uuid +end note + +@enduml diff --git a/uml/class-diagrams/components/user/UserRepository.puml b/uml/class-diagrams/components/user/UserRepository.puml new file mode 100644 index 000000000..041fe06be --- /dev/null +++ b/uml/class-diagrams/components/user/UserRepository.puml @@ -0,0 +1,33 @@ +@startuml +package com.greybox.projectmesh.user { + interface UserDao { + +getUserByUuid(uuid: String): UserEntity? + +insertUser(user: UserEntity) + +updateUser(user: UserEntity) + +hasWithID(uuid: String): Boolean + +getUserByIp(ip: String): UserEntity? + +getAllConnectedUsers(): List + +getAllUsers(): List + } + + class UserEntity { + +uuid: String + +name: String + +address: String? + +lastSeen: Long? + } + + class UserRepository { + -userDao: UserDao + +insertOrUpdateUser(uuid: String, name: String, address: String?) + +getUserByIp(ip: String): UserEntity? + +getUser(uuid: String): UserEntity? + +getAllConnectedUsers(): List + +getAllUsers(): List + +hasUser(uuid: String): Boolean + } + + UserRepository --> UserDao : uses + UserDao --> UserEntity : returns/accepts +} +@enduml diff --git a/uml/class-diagrams/components/util/NotificationHelper.puml b/uml/class-diagrams/components/util/NotificationHelper.puml new file mode 100644 index 000000000..2bcf4d5d1 --- /dev/null +++ b/uml/class-diagrams/components/util/NotificationHelper.puml @@ -0,0 +1,18 @@ +@startuml NotificationHelper +skinparam classAttributeIconSize 0 + +class NotificationHelper <> { + -CHANNEL_ID: String = "file_receive_channel" + -CHANNEL_NAME: String = "File Receive Notifications" + +createNotificationChannel(context: Context): Unit + +showFileReceivedNotification(context: Context, fileName: String): Unit +} + +NotificationHelper --> NotificationManager +NotificationHelper --> NotificationChannel +NotificationHelper --> NotificationCompat.Builder +NotificationHelper --> PendingIntent +NotificationHelper --> MainActivity +NotificationHelper --> BottomNavItem + +@enduml diff --git a/uml/class-diagrams/components/views/Onboardingscreen.puml b/uml/class-diagrams/components/views/Onboardingscreen.puml new file mode 100644 index 000000000..fc5fce142 --- /dev/null +++ b/uml/class-diagrams/components/views/Onboardingscreen.puml @@ -0,0 +1,81 @@ +@startuml +title OnboardingScreen.kt (Compose) - DI + ViewModel Factory + Next Flow + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class OnboardingScreen <> { + +OnboardingScreen(onComplete: ()->Unit) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class OnboardingViewModel <> { + +uiState: StateFlow + +onUsernameChange(newValue: String) + +blankUsernameGenerator(cb: (String)->Unit) + +handleFirstTimeSetup(cb: ()->Unit) + } + + class OnboardingUiState <> { + +username: String? + } +} + +package "DI / App Globals" as Infra { + class localDI + class GlobalApp { + +GlobalUserRepo + } + class GlobalUserRepo { + +userRepository + +prefs + } + class getLocalIpFromDI <> { + +getLocalIpFromDI(di): String + } + class ViewModelProviderFactory as "ViewModelProvider.Factory" +} + +package "Compose UI" as UI { + class Column + class Text + class TextField + class Button + class Spacer +} + +' ---- Wiring / creation ---- +Views.OnboardingScreen ..> Infra.localDI : get DI +Views.OnboardingScreen ..> Infra.getLocalIpFromDI : localIp = getLocalIpFromDI(di) +Views.OnboardingScreen ..> Infra.ViewModelProviderFactory : remember { custom factory } +Views.OnboardingScreen ..> VM.OnboardingViewModel : viewModel(factory) +Views.OnboardingScreen ..> VM.OnboardingUiState : collectAsState(uiState) + +note right of Infra.ViewModelProviderFactory +Factory constructs OnboardingViewModel with: +- GlobalApp.GlobalUserRepo.userRepository +- GlobalApp.GlobalUserRepo.prefs +- localIp (from DI) +end note + +' ---- UI events ---- +UI.TextField ..> VM.OnboardingViewModel : onValueChange -> onUsernameChange(newValue) + +note bottom of UI.Button +Button "Next" onClick logic: + +IF uiState.username is null OR blank: + onboardingViewModel.blankUsernameGenerator { generatedName -> + onboardingViewModel.onUsernameChange(generatedName) + onboardingViewModel.handleFirstTimeSetup { onComplete() } + } +ELSE: + onboardingViewModel.handleFirstTimeSetup { onComplete() } + +Then (BUG / duplicate call): + onboardingViewModel.handleFirstTimeSetup { onComplete() } // called again regardless +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/homescreen.puml b/uml/class-diagrams/components/views/homescreen.puml new file mode 100644 index 000000000..0ab79daa2 --- /dev/null +++ b/uml/class-diagrams/components/views/homescreen.puml @@ -0,0 +1,184 @@ +@startuml +title HomeScreen.kt (Compose) - View + Flow Overview + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class HomeScreen <> { + +HomeScreen(viewModel: HomeScreenViewModel, deviceName: String?) + } + + class StartHomeScreen <> { + +StartHomeScreen(uiState: HomeScreenModel, node: AndroidVirtualNode, ... ) + } + + class LongPressCopyableText <> { + +LongPressCopyableText(context: Context, text: String, textCopyable: String, ...) + } + + class QRCodeView <> { + +QRCodeView(qrcodeUri: String, barcodeEncoder: BarcodeEncoder, ssid: String?, ...) + } + + class NoConcurrencyWarningDialog <> { + +NoConcurrencyWarningDialog(onDismiss: () -> Unit) + } + + class ConcurrencyWarningDialog <> { + +ConcurrencyWarningDialog(onDismiss: () -> Unit) + } + + class stopHotspotConfirmationDialog <> { + +stopHotspotConfirmationDialog(context: Context, onConfirm: (Boolean)->Unit) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class HomeScreenViewModel <> { + +uiState: StateFlow + +concurrencyKnown: StateFlow + +concurrencySupported: StateFlow + +showNoConcurrencyWarning: Flow + +showConcurrencyWarning: Flow + + +onSetIncomingConnectionsEnabled(enabled: Boolean) + +onClickDisconnectStation() + +onConnectBandChanged(band: ConnectBand) + +onSetHotspotTypeToCreate(type: HotspotType) + +onConnectWifi(config: HotspotConfig) + + +saveConcurrencyKnown(value: Boolean) + +saveConcurrencySupported(value: Boolean) + +dismissNoConcurrencyWarning() + +dismissConcurrencyWarning() + } + + class HomeScreenModel <> { + +localAddress + +connectBandVisible: Boolean + +bandMenu: List + +band: ConnectBand + + +wifiConnectionEnabled: Boolean + +hotspotTypeMenu: List + +hotspotTypeToCreate: HotspotType + + +wifiState + +hotspotStatus: Boolean + +connectUri: String? + +nodesOnMesh: Set + + +hotspotStatus: Boolean + } +} + +package "Meshrabiya / Wifi" as Mesh { + class AndroidVirtualNode + class VirtualNode + class MeshrabiyaConnectLink { + +parseUri(uri: String, json: Any): Parsed + } + class ConnectWifiLauncherResult { + +hotspotConfig + +exception + } + enum ConnectWifiLauncherStatus { + INACTIVE + ... + } + class BarcodeEncoder + class ScanContract + class ScanOptions + class WifiStationState { + +status + +config + } + enum HotspotType { + WIFIDIRECT_GROUP + ... + } + enum ConnectBand + class MNetLogger +} + +package "Android / Compose Runtime" as Android { + class Context + class WifiManager + class Intent + class Toast + class PackageManager + class AlertDialogBuilder as "AlertDialog.Builder" + class PermissionLauncher as "rememberLauncherForActivityResult(RequestPermission)" + class QRScannerLauncher as "rememberLauncherForActivityResult(ScanContract)" +} + +' ---------- Structural links ---------- +HomeScreen --> StartHomeScreen : composes +HomeScreen ..> HomeScreenViewModel : collects uiState\ncollectAsState() +HomeScreen ..> VirtualNode : DI provides node +HomeScreen ..> MNetLogger : DI provides logger +HomeScreen ..> PermissionLauncher : nearby-wifi permission + +StartHomeScreen ..> HomeScreenViewModel : collects warnings\n(showNoConcurrencyWarning/showConcurrencyWarning) +StartHomeScreen ..> AndroidVirtualNode : uses node +StartHomeScreen ..> BarcodeEncoder : remembers encoder +StartHomeScreen ..> QRScannerLauncher : QR scan launcher +StartHomeScreen ..> ConnectWifiLauncherStatus : connectLauncherState +StartHomeScreen ..> ConnectWifiLauncherResult : onResult callback +StartHomeScreen ..> MeshrabiyaConnectLink : parse connect link +StartHomeScreen ..> ScanOptions : launches scan +StartHomeScreen --> LongPressCopyableText : shows device info +StartHomeScreen --> QRCodeView : shows QR + credentials +StartHomeScreen --> NoConcurrencyWarningDialog +StartHomeScreen --> ConcurrencyWarningDialog +StartHomeScreen --> stopHotspotConfirmationDialog + +stopHotspotConfirmationDialog ..> "AlertDialog.Builder" : build + show + +' ---------- Key UI triggers -> ViewModel ---------- +StartHomeScreen ..> HomeScreenViewModel : onSetIncomingConnectionsEnabled(enabled)\n(Start/Stop Hotspot) +StartHomeScreen ..> HomeScreenViewModel : onClickDisconnectStation()\n(Disconnect WiFi Station) +StartHomeScreen ..> HomeScreenViewModel : onConnectBandChanged(band)\n(FilterChip) +StartHomeScreen ..> HomeScreenViewModel : onSetHotspotTypeToCreate(type)\n(FilterChip) +StartHomeScreen ..> HomeScreenViewModel : onConnectWifi(hotspotConfig)\n(onConnectWifiLauncherResult) + +NoConcurrencyWarningDialog ..> HomeScreenViewModel : dismissNoConcurrencyWarning() +ConcurrencyWarningDialog ..> HomeScreenViewModel : dismissConcurrencyWarning() + +' ---------- Behavior notes / guards (as notes) ---------- +note right of HomeScreen +- On enable hotspot: + * If missing nearby-wifi/location permission -> launch permission request + * If hotspotTypeToCreate == WIFIDIRECT_GROUP: + require WiFi enabled (WifiManager.isWifiEnabled) else Toast and abort +- Concurrency probing (Android >= 11): + if !concurrencyKnown -> saveConcurrencyKnown(true), + saveConcurrencySupported(context.hasStaApConcurrency()) +- Error dialog shown when connect launcher returns exception +end note + +note right of StartHomeScreen +Connect flow: +- QR scan OR manual URI entry -> connect(uri) +- connect(uri): + * parse MeshrabiyaConnectLink -> hotspotConfig + * if hotspotConfig null -> Toast + log + * if already in uiState.nodesOnMesh -> Toast + log + * else connectLauncher.launch(hotspotConfig) +- connectLauncher onResult: + * hotspotConfig != null -> viewModel.onConnectWifi(config) + * else set errorMessage (shown by HomeScreen) +end note + +note bottom of LongPressCopyableText +Long press -> copies textCopyable to clipboard +and shows Toast "Text copied to clipboard!" +end note + +note bottom of QRCodeView +Generates QR bitmap from connectUri and displays +SSID / Password / MAC / Port next to it +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/logscreen.puml b/uml/class-diagrams/components/views/logscreen.puml new file mode 100644 index 000000000..affee9227 --- /dev/null +++ b/uml/class-diagrams/components/views/logscreen.puml @@ -0,0 +1,105 @@ +@startuml +title LogScreen.kt (Compose) - View + Selection/Copy Flow + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class LogScreen <> { + +LogScreen(viewModel: LogScreenViewModel) + } + + class ShowLogScreen <> { + +ShowLogScreen(uiState: LogScreenModel) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class LogScreenViewModel <> { + +uiState: StateFlow + } + + class LogScreenModel <> { + +logs: List + } + + class LogLine <> { + +lineId: Int + +time: Long + +line: String + } +} + +package "Android / Compose Runtime" as Android { + class LocalContext + class Toast + class Clipboard as "LocalClipboardManager" + class AnnotatedString + class SimpleDateFormat + class Date +} + +package "DI / Logging" as DI { + class localDI + class MNetLogger + class MNetLoggerAndroid +} + +' ----- Structure ----- +LogScreen --> LogScreenViewModel : inject via ViewModelFactory +LogScreen ..> LogScreenModel : collectAsState(initial) +LogScreen --> ShowLogScreen : passes uiState + +ShowLogScreen ..> LocalContext : context +ShowLogScreen ..> localDI : get logger +ShowLogScreen ..> MNetLogger : instance() +ShowLogScreen ..> MNetLoggerAndroid : cast +ShowLogScreen ..> Clipboard : clipboardManager +ShowLogScreen ..> SimpleDateFormat : remember("HH:mm:ss.SS") + +' ----- UI State (local) ----- +note right of ShowLogScreen +Local UI state: +- selectionMode: Boolean +- selectedLineIds: Set + +Derived per line: +- formattedTime = formatter.format(Date(time)) +- logText = "[time] line" +- isSelected = lineId in selectedLineIds +end note + +' ----- Selection toolbar flows ----- +note left of ShowLogScreen +Toolbar visible only when selectionMode == true + +Buttons: +1) Select All: + selectedLineIds = all uiState.logs.lineId + +2) Copy: + selectedLogs = uiState.logs filtered by selectedLineIds + joinToString("\n") -> "[time] line" + clipboardManager.setText(AnnotatedString(selectedLogs)) + Toast("Logs copied!") + selectionMode=false + selectedLineIds=empty + +3) Cancel: + selectionMode=false + selectedLineIds=empty +end note + +' ----- Gesture flows per log row ----- +note bottom of VM.LogLine +Row gesture handling: +- onLongPress: + if !selectionMode -> selectionMode=true, selectedLineIds={lineId} +- onTap: + if selectionMode: + toggle membership of lineId in selectedLineIds +- Checkbox (only if selectionMode): + checked change -> add/remove lineId +end note + +@enduml diff --git a/uml/class-diagrams/components/views/networkscreen.puml b/uml/class-diagrams/components/views/networkscreen.puml new file mode 100644 index 000000000..6176c059e --- /dev/null +++ b/uml/class-diagrams/components/views/networkscreen.puml @@ -0,0 +1,70 @@ +@startuml +title NetworkScreen.kt (Compose) - View + Node Click Flow + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class NetworkScreen <> { + +NetworkScreen(onNodeClick: (String)->Unit, viewModel: NetworkScreenViewModel) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class NetworkScreenViewModel <> { + +uiState: StateFlow + } + + class NetworkScreenModel <> { + +allNodes: Map + } +} + +package "com.greybox.projectmesh.extension" as Ext { + class WifiListItem <> { + +WifiListItem(wifiAddress: String, wifiEntry: WifiEntry, onClick: (String)->Unit) + } + class WifiEntry <> +} + +package "com.greybox.projectmesh.server" as Server { + class AppServer { + +requestRemoteUserInfo(addr: InetAddress) + +pushUserInfoTo(addr: InetAddress) + } +} + +package "Android / Java" as Android { + class InetAddress { + +getByName(host: String): InetAddress + } +} + +package "DI" as DI { + class localDI +} + +' ---- Composition + data flow ---- +NetworkScreen ..> NetworkScreenViewModel : collectAsState(uiState) +NetworkScreen ..> NetworkScreenModel : reads allNodes +NetworkScreen ..> localDI : resolve AppServer +NetworkScreen ..> AppServer : instance() + +NetworkScreen --> WifiListItem : renders per node\n(items = allNodes.entries) + +' ---- Click behavior ---- +WifiListItem ..> InetAddress : onClick(ip)->getByName(ip) +WifiListItem ..> AppServer : requestRemoteUserInfo(addr)\npushUserInfoTo(addr) +WifiListItem ..> NetworkScreen : calls onNodeClick(ip)\n(navigate) + +note right of Views.NetworkScreen +For each (ipAddress -> wifiEntry) in uiState.allNodes: +- Render WifiListItem +- On item click: + 1) addr = InetAddress.getByName(ipAddress) + 2) appServer.requestRemoteUserInfo(addr) + 3) appServer.pushUserInfoTo(addr) + 4) onNodeClick(ipAddress) // navigation to Ping Screen +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/pingscreen.puml b/uml/class-diagrams/components/views/pingscreen.puml new file mode 100644 index 000000000..5c11c6976 --- /dev/null +++ b/uml/class-diagrams/components/views/pingscreen.puml @@ -0,0 +1,86 @@ +@startuml +title PingScreen.kt (Compose) - ViewModel Injection + Ping List Rendering + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class PingScreen <> { + +PingScreen(virtualAddress: InetAddress, viewModel: PingScreenViewModel) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class PingScreenViewModel <> { + +uiState: StateFlow + <> + } + + class PingScreenModel <> { + +deviceName: String? + +virtualAddress: InetAddress + +allOriginatorMessages: List + } + + class OriginatorMessageItem <> { + +originatorMessage: MmcpOriginatorMessage + +hopCount: Int + +lastHopAddr: InetAddress + } +} + +package "MMCP / Meshrabiya" as Mesh { + class MmcpOriginatorMessage { + +pingTimeSum: Long + +messageId: Long + } + class addressToDotNotation <> { + +InetAddress.addressToDotNotation(): String + } +} + +package "DI" as DI { + class localDI + class ViewModelFactory + class LocalSavedStateRegistryOwner +} + +package "Compose UI" as UI { + class LazyColumn + class Row + class Text + class Spacer +} + +package "Java Net" as Net { + class InetAddress +} + +' ---- Construction / wiring ---- +Views.PingScreen ..> DI.localDI : provide DI +Views.PingScreen ..> DI.LocalSavedStateRegistryOwner : owner +Views.PingScreen ..> DI.ViewModelFactory : creates PingScreenViewModel(di, savedStateHandle, virtualAddress) +Views.PingScreen ..> VM.PingScreenViewModel : viewModel(factory) +Views.PingScreen ..> VM.PingScreenModel : collectAsState(initial) + +' ---- Rendering ---- +Views.PingScreen --> UI.LazyColumn : renders list +UI.LazyColumn --> UI.Row : header row +UI.Row --> UI.Text : "Device name: ..., IP address: ..." + +UI.LazyColumn --> VM.OriginatorMessageItem : iterates allOriginatorMessages +VM.OriginatorMessageItem ..> Mesh.MmcpOriginatorMessage : originatorMessage +VM.OriginatorMessageItem ..> Net.InetAddress : lastHopAddr +Net.InetAddress ..> Mesh.addressToDotNotation : lastHopAddr.addressToDotNotation() + +note right of Views.PingScreen +Per originator message item: +- mmcpMessage = item.originatorMessage +- Render: + "Ping: {pingTimeSum}ms, + hops: {hopCount}, + last hop: {lastHopAddr.addressToDotNotation()}, + id: {messageId}" +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/recievescreen-1.puml b/uml/class-diagrams/components/views/recievescreen-1.puml new file mode 100644 index 000000000..a8baee4c4 --- /dev/null +++ b/uml/class-diagrams/components/views/recievescreen-1.puml @@ -0,0 +1,179 @@ +@startuml +title ReceiveScreen.kt (Compose) - Incoming Transfers + Auto-Accept + Actions (Open/Download/Delete) + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class ReceiveScreen <> { + +ReceiveScreen(viewModel: ReceiveScreenViewModel, onAutoFinishChange: (Boolean) -> Unit) + } + + class HandleIncomingTransfers <> { + +HandleIncomingTransfers( + uiState: ReceiveScreenModel, + onAccept, + onDecline, + onDelete, + onAutoFinishChange + ) + } + + class onDownload <> { + +onDownload(context: Context, transfer: IncomingTransferInfo, uriOrPath: String) + } + + class saveFileToDefaultPath <> + class saveFileToMediaStore <> + class saveFileToContentUri <> +} + +package "com.greybox.projectmesh.viewModel" as VM { + class ReceiveScreenViewModel <> { + +uiState: StateFlow + +onAccept(transfer: IncomingTransferInfo) + +onDecline(transfer: IncomingTransferInfo) + +onDelete(transfer: IncomingTransferInfo) + } + + class ReceiveScreenModel <> { + +incomingTransfers: List + } +} + +package "com.greybox.projectmesh.server" as Server { + class AppServer + + enum Status { + PENDING + COMPLETED + DECLINED + FAILED + } + + class IncomingTransferInfo { + +id + +name + +deviceName + +fromHost: InetAddress + +requestReceivedTime + +status: Status + +transferred + +size + +transferTime + +file: File? + } +} + +package "Android / Storage / Intents" as Android { + class Context + class SharedPreferences + class Environment + class FileProvider + class Intent + class MimeTypeMap + class Toast + class Uri + class DocumentsContract + class MediaStore + class ContentResolver + class ContentValues + class File + class Build + class InetAddress +} + +package "DI" as DIBlock { + class localDI + class "DI" as DIContainer +} + +package "Compose UI" as UI { + class LazyColumn + class ListItem + class IconButton + class Icon + class Text + class Column + class Row + class Spacer + class HorizontalDivider + class LaunchedEffect +} + +package "Formatting Helpers" as Helpers { + class autoConvertByte <> + class autoConvertMS <> +} + +Views.ReceiveScreen ..> VM.ReceiveScreenViewModel : collectAsState(uiState) +Views.ReceiveScreen --> Views.HandleIncomingTransfers : passes uiState + VM callbacks + +Views.HandleIncomingTransfers ..> DIBlock.localDI : resolve DI +Views.HandleIncomingTransfers ..> Android.SharedPreferences : instance(tag="settings") +Views.HandleIncomingTransfers ..> Android.Context : LocalContext +Views.HandleIncomingTransfers ..> Android.Environment : Downloads fallback +Views.HandleIncomingTransfers ..> Server.IncomingTransferInfo : displays +Views.HandleIncomingTransfers ..> Server.Status : checks status + +VM.ReceiveScreenViewModel ..> VM.ReceiveScreenModel : exposes state +VM.ReceiveScreenViewModel ..> Server.IncomingTransferInfo : accepts / declines / deletes + +VM.ReceiveScreenModel ..> Server.IncomingTransferInfo : holds list + +Server.IncomingTransferInfo ..> Server.Status : uses +Server.IncomingTransferInfo ..> Android.File : optional file +Server.IncomingTransferInfo ..> Android.InetAddress : fromHost + +note right of Views.HandleIncomingTransfers +defaultUri: +- settingPref.getString("save_to_folder", null) +- else Downloads/Project Mesh + +autoFinishEnabled: +- loaded from settingPref.getBoolean("auto_finish", false) +end note + +UI.LaunchedEffect ..> Android.SharedPreferences : load auto_finish +UI.LaunchedEffect ..> VM.ReceiveScreenModel : reacts to incomingTransfers + +note right of UI.LaunchedEffect +Auto-finish behavior: +If autoFinishEnabled == true, +accept each transfer with status == PENDING +end note + +Views.HandleIncomingTransfers --> UI.LazyColumn : render incomingTransfers +UI.LazyColumn --> UI.ListItem : per transfer + +note left of UI.ListItem +ListItem click: +open file only if +- transfer.file != null +- status == COMPLETED + +Uses FileProvider + ACTION_VIEW intent. +If no handler exists, show Toast. +On exception, show error Toast. +end note + +note bottom of Views.HandleIncomingTransfers +Per transfer status: +- PENDING: Accept / Decline +- COMPLETED: Delete / Download +- DECLINED or FAILED: Delete +end note + +Views.onDownload ..> Views.saveFileToContentUri : if path starts with content:// +Views.onDownload ..> Views.saveFileToDefaultPath : otherwise + +Views.saveFileToDefaultPath ..> Views.saveFileToMediaStore : if SDK >= Q +Views.saveFileToDefaultPath ..> Android.File : legacy file copy + +Views.saveFileToMediaStore ..> Android.MediaStore : insert into Downloads +Views.saveFileToContentUri ..> Android.DocumentsContract : createDocument + write stream + +Views.HandleIncomingTransfers ..> Helpers.autoConvertByte : format size +Views.HandleIncomingTransfers ..> Helpers.autoConvertMS : format transfer time + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/requestpermissionscreen.puml b/uml/class-diagrams/components/views/requestpermissionscreen.puml new file mode 100644 index 000000000..9d063bb1a --- /dev/null +++ b/uml/class-diagrams/components/views/requestpermissionscreen.puml @@ -0,0 +1,130 @@ +@startuml +title RequestPermissionsScreen.kt (Compose) - Permission Step State Machine + Battery Optimization Prompt + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class RequestPermissionsScreen <> { + +RequestPermissionsScreen(skipPermissions: Boolean) + } + + class hasPermission <> { + +hasPermission(context: Context, permission: String): Boolean + } + + class hasAnyPermission <> { + +hasAnyPermission(context: Context, permissions: Array): Boolean + } + + class isBatteryOptimizationDisabled <> { + +isBatteryOptimizationDisabled(context: Context): Boolean + } + + class promptDisableBatteryOptimization <> { + +promptDisableBatteryOptimization(context: Context) + } +} + +package "Compose Runtime" as Compose { + class rememberLauncherForActivityResult + class ActivityResultContracts + class LaunchedEffect + class LocalContext + class mutableIntStateOf +} + +package "Android Framework" as Android { + class Context + class Manifest + class Build + class PackageManager + class ContextCompat + class PowerManager + class AlertDialogBuilder as "AlertDialog.Builder" + class Intent + class Settings + class SpannableString + class StyleSpan + class Typeface + class Spanned +} + +' ---- State / initialization ---- +Views.RequestPermissionsScreen ..> Compose.LocalContext : context +Views.RequestPermissionsScreen ..> Compose.mutableIntStateOf : currentStep\n(skipPermissions ? 6 : 0) + +note right of Views.RequestPermissionsScreen +currentStep meanings: +0 - Nearby Wi-Fi permission +1 - Location permission +2 - Notification permission (Android 13+) +3 - Storage permission(s) (Android 13+ uses READ_MEDIA_*) +4 - Camera permission +5 - Battery optimization prompt +6 - Done / skip +end note + +' ---- Launchers ---- +Views.RequestPermissionsScreen ..> Compose.rememberLauncherForActivityResult : nearbyWifiPermissionLauncher (RequestPermission) +Views.RequestPermissionsScreen ..> Compose.rememberLauncherForActivityResult : locationPermissionLauncher (RequestPermission) +Views.RequestPermissionsScreen ..> Compose.rememberLauncherForActivityResult : notificationPermissionLauncher (RequestPermission) +Views.RequestPermissionsScreen ..> Compose.rememberLauncherForActivityResult : storagePermissionLauncher (RequestMultiplePermissions) +Views.RequestPermissionsScreen ..> Compose.rememberLauncherForActivityResult : cameraPermissionLauncher (RequestPermission) + +note bottom of Compose.rememberLauncherForActivityResult +Launcher callbacks advance currentStep: +- nearbyWifi -> step=1 +- location -> step=2 +- notification -> step=3 +- storage -> step=4 +- camera -> step=5 +end note + +' ---- Step machine ---- +Views.RequestPermissionsScreen ..> Compose.LaunchedEffect : reacts to currentStep + +Views.RequestPermissionsScreen ..> Views.hasPermission : check single permission +Views.RequestPermissionsScreen ..> Views.hasAnyPermission : check storage group +Views.RequestPermissionsScreen ..> Views.isBatteryOptimizationDisabled : check battery opt +Views.RequestPermissionsScreen ..> Views.promptDisableBatteryOptimization : show dialog + +note right of Compose.LaunchedEffect +On currentStep change: + +If currentStep == 6 -> return + +Step 0: + if SDK>=M and !has NEARBY_WIFI_DEVICES -> launch request + else currentStep=1 + +Step 1: + if SDK>=M and !has ACCESS_FINE_LOCATION -> launch request + else currentStep=2 + +Step 2: + if SDK>=TIRAMISU and !has POST_NOTIFICATIONS -> launch request + else currentStep=3 + +Step 3: + storagePermissions = + if SDK>=TIRAMISU -> [READ_MEDIA_IMAGES, READ_MEDIA_VIDEO] + else -> [READ_EXTERNAL_STORAGE] + if !hasAnyPermission(storagePermissions) -> launch multiple request + else currentStep=4 + +Step 4: + if !has CAMERA -> launch request + else currentStep=5 + +Step 5: + if !isBatteryOptimizationDisabled -> promptDisableBatteryOptimization +end note + +' ---- Battery optimization dialog ---- +Views.promptDisableBatteryOptimization ..> Android.SpannableString : build message + bold spans +Views.promptDisableBatteryOptimization ..> Android.AlertDialogBuilder : show dialog +Views.promptDisableBatteryOptimization ..> Android.Intent : OPEN SETTINGS / fallback App Details +Views.promptDisableBatteryOptimization ..> Android.Settings : ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS\nACTION_APPLICATION_DETAILS_SETTINGS + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/selectdestnodescreen.puml b/uml/class-diagrams/components/views/selectdestnodescreen.puml new file mode 100644 index 000000000..6346e02de --- /dev/null +++ b/uml/class-diagrams/components/views/selectdestnodescreen.puml @@ -0,0 +1,80 @@ +@startuml +title SelectDestNodeScreen.kt (Compose) - ViewModel Injection + In-Progress vs Node List UI + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class SelectDestNodeScreen <> { + +SelectDestNodeScreen(uris: List, popBackWhenDone: ()->Unit, viewModel: SelectDestNodeScreenViewModel) + } + + class DisplayAllNodesToSelect <> { + +DisplayAllNodesToSelect(uiState: SelectDestNodeScreenModel, onClickReceiver: (Int)->Unit) + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class SelectDestNodeScreenViewModel <> { + +uiState: StateFlow + +onClickReceiver(key: Int) + <> + } + + class SelectDestNodeScreenModel <> { + +contactingInProgressDevice: String? + +allNodes: Map + } +} + +package "com.greybox.projectmesh.extension" as Ext { + class WifiListItem <> { + +WifiListItem(wifiAddress: Int, wifiEntry: WifiEntry, onClick: (Int)->Unit) + } + class WifiEntry <> +} + +package "DI" as DI { + class localDI + class ViewModelFactory + class LocalSavedStateRegistryOwner +} + +package "Android" as Android { + class Uri +} + +package "Compose UI" as UI { + class LazyColumn + class Column + class CircularProgressIndicator + class Text +} + +' ---- Wiring ---- +Views.SelectDestNodeScreen ..> DI.localDI : provide DI +Views.SelectDestNodeScreen ..> DI.LocalSavedStateRegistryOwner : owner +Views.SelectDestNodeScreen ..> DI.ViewModelFactory : creates VM(di, savedStateHandle, uris, popBackWhenDone) +Views.SelectDestNodeScreen ..> VM.SelectDestNodeScreenViewModel : viewModel(factory) +Views.SelectDestNodeScreen ..> VM.SelectDestNodeScreenModel : collectAsState(initial) +Views.SelectDestNodeScreen --> Views.DisplayAllNodesToSelect : uiState + onClickReceiver + +' ---- UI branching ---- +note right of Views.DisplayAllNodesToSelect +Branching logic: +if uiState.contactingInProgressDevice != null: + show progress item: + - CircularProgressIndicator + - Text("Contacting {device}\nThis might take a few seconds.") +else: + show node list: + for each (key, wifiEntry) in uiState.allNodes.entries.toList(): + WifiListItem(wifiAddress=key, wifiEntry=value) + onClick -> onClickReceiver(key) +end note + +Views.DisplayAllNodesToSelect --> UI.LazyColumn : container +UI.LazyColumn --> UI.Column : in-progress item (optional) +UI.LazyColumn --> Ext.WifiListItem : list items (else branch) + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/sendscreen.puml b/uml/class-diagrams/components/views/sendscreen.puml new file mode 100644 index 000000000..fcd6ccd80 --- /dev/null +++ b/uml/class-diagrams/components/views/sendscreen.puml @@ -0,0 +1,133 @@ +@startuml +title SendScreen.kt (Compose) - File Picker + Outgoing Transfers List + Swipe-to-Delete + Formatting Helpers + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class SendScreen <> { + +SendScreen(onSwitchToSelectDestNode: (List)->Unit, viewModel: SendScreenViewModel) + } + + class DisplayAllPendingTransfers <> { + +DisplayAllPendingTransfers(viewModel: SendScreenViewModel, uiState: SendScreenModel) + } + + class autoConvertByte <> { + +autoConvertByte(byteSize: Int): String + } + + class autoConvertMS <> { + +autoConvertMS(ms: Int): String + } +} + +package "com.greybox.projectmesh.viewModel" as VM { + class SendScreenViewModel <> { + +uiState: StateFlow + +onFileChosen(uris: List) + +onDelete(transfer: OutgoingTransfer) + <> + } + + class SendScreenModel <> { + +outgoingTransfers: List + } + + class OutgoingTransfer <> { + +id: Any + +name: String + +toHost: InetAddress + +size: Int + +transferred: Int + +status: Any + } +} + +package "Global / Repo" as Global { + class GlobalApp { + +GlobalUserRepo + } + class UserRepository { + +getUserByIp(ip: String): User? + } + class User { + +name: String + } +} + +package "Android / Compose" as Android { + class Uri + class ActivityResultContractsOpenMultipleDocuments as "ActivityResultContracts.OpenMultipleDocuments" + class rememberLauncherForActivityResult + class runBlocking +} + +package "Compose UI / Material3" as UI { + class Box + class Column + class LazyColumn + class ListItem + class Text + class TransparentButton + class AnimatedVisibility + class fadeOut + class SwipeToDismissBox + class rememberSwipeToDismissBoxState + class HorizontalDivider + class Icon + class rememberCoroutineScope + class delay +} + +package "DI" as DI { + class localDI + class ViewModelFactory + class LocalSavedStateRegistryOwner +} + +' --- Wiring --- +Views.SendScreen ..> DI.localDI : provide DI +Views.SendScreen ..> DI.LocalSavedStateRegistryOwner : owner +Views.SendScreen ..> DI.ViewModelFactory : creates SendScreenViewModel(di, savedStateHandle, onSwitchToSelectDestNode) +Views.SendScreen ..> VM.SendScreenViewModel : viewModel(factory) +Views.SendScreen ..> VM.SendScreenModel : collectAsState(uiState) + +Views.SendScreen ..> Android.rememberLauncherForActivityResult : openDocumentLauncher(OpenMultipleDocuments) +Views.SendScreen ..> Android.ActivityResultContractsOpenMultipleDocuments : pick files +Views.SendScreen ..> VM.SendScreenViewModel : onFileChosen(uris)\n(if uris not empty) + +Views.SendScreen --> Views.DisplayAllPendingTransfers : render list section +Views.SendScreen --> UI.TransparentButton : "Send File"\nlaunch(arrayOf("*/*")) + +' --- List + swipe delete --- +Views.DisplayAllPendingTransfers --> UI.LazyColumn : items(outgoingTransfers, key=id) + +UI.rememberSwipeToDismissBoxState ..> VM.SendScreenViewModel : onDelete(transfer)\n(after 300ms delay) +UI.AnimatedVisibility ..> UI.fadeOut : exit animation 300ms +UI.SwipeToDismissBox --> UI.ListItem : content +UI.SwipeToDismissBox --> UI.Icon : backgroundContent (red + delete icon) +UI.SwipeToDismissBox ..> UI.rememberCoroutineScope : launch { delay(300); onDelete() } + +note right of Views.DisplayAllPendingTransfers +Swipe behavior: +- Only EndToStart allowed (right->left) +- On confirm EndToStart: + isVisible=false (fadeOut 300ms) + delay(300) + viewModel.onDelete(transfer) +end note + +' --- Device name lookup --- +note bottom of UI.ListItem +For each transfer: +- toHostAddress = transfer.toHost.hostAddress +- deviceName resolved via runBlocking: + GlobalApp.GlobalUserRepo.userRepository.getUserByIp(toHostAddress)?.name +UI shows: +- "To: {deviceName} ({ip})" or "To: Loading... ({ip})" +- status +- send progress using autoConvertByte(transferred/size) +end note + +@enduml \ No newline at end of file diff --git a/uml/class-diagrams/components/views/settingscreen.puml b/uml/class-diagrams/components/views/settingscreen.puml new file mode 100644 index 000000000..9fb94eebe --- /dev/null +++ b/uml/class-diagrams/components/views/settingscreen.puml @@ -0,0 +1,162 @@ +@startuml +title SettingsScreen.kt (Compose) - Settings Sections + Callbacks + Dialog + Folder Picker + +skinparam packageStyle rectangle +skinparam shadowing false + +package "com.greybox.projectmesh.views" as Views { + class SettingsScreen <> { + +SettingsScreen(viewModel: SettingsScreenViewModel,\n onThemeChange,\n onLanguageChange,\n onRestartServer,\n onDeviceNameChange,\n onAutoFinishChange,\n onSaveToFolderChange) + } + + class SectionHeader <> + class SettingItem <> + class LanguageSetting <> + class ThemeSetting <> + class ChangeDeviceNameDialog <> +} + +package "com.greybox.projectmesh.viewModel" as VM { + class SettingsScreenViewModel <> { + +theme: StateFlow + +lang: StateFlow + +deviceName: StateFlow + +autoFinish: StateFlow + +saveToFolder: StateFlow + +saveTheme(theme: AppTheme) + +saveLang(lang: String) + +saveDeviceName(name: String) + +saveAutoFinish(enabled: Boolean) + +saveSaveToFolder(pathOrUri: String) + +updateConcurrencySettings(known: Boolean, supported: Boolean) + } + + enum AppTheme { + System + Light + Dark + } +} + +package "DI" as DI { + class localDI + class ViewModelFactory + class LocalSavedStateRegistryOwner + class SharedPreferences +} + +package "Android" as Android { + class Context + class Intent + class Uri + class Build + class Toast + class ContentResolver +} + +package "Compose UI / Material3" as UI { + class Column + class Row + class Text + class Spacer + class HorizontalDivider + class Switch + class DropdownMenu + class DropdownMenuItem + class TextButton + class TextField + class Dialog + class Surface + class rememberLauncherForActivityResult + class ActivityResultContractsOpenDocumentTree as "ActivityResultContracts.OpenDocumentTree" + class PopupProperties +} + +package "Project UI Components" as Comp { + class GradientButton + class GradientLongButton +} + +' ---- Top wiring ---- +Views.SettingsScreen ..> DI.localDI +Views.SettingsScreen ..> DI.ViewModelFactory : creates SettingsScreenViewModel +Views.SettingsScreen ..> VM.SettingsScreenViewModel : viewModel(factory) + +Views.SettingsScreen ..> UI.rememberLauncherForActivityResult : directoryLauncher(OpenDocumentTree) +Views.SettingsScreen ..> DI.SharedPreferences : instance(tag="settings")\n(read only in this file) + +note right of Views.SettingsScreen +UI State sources from ViewModel: +- currTheme = theme.collectAsState() +- currLang = lang.collectAsState() +- currDeviceName = deviceName.collectAsState() +- currAutoFinish = autoFinish.collectAsState() +- currSaveToFolder = saveToFolder.collectAsState() + +Internal UI state: +- showDialog: Boolean (device name dialog visibility) +end note + +' ---- Section structure ---- +Views.SettingsScreen --> Views.SectionHeader : "General" +Views.SettingsScreen --> Views.SettingItem : Language (LanguageSetting) +Views.SettingsScreen --> Views.SettingItem : Theme (ThemeSetting) + +Views.SettingsScreen --> Views.SectionHeader : "Network" +Views.SettingsScreen --> Views.SettingItem : Server Restart (GradientButton -> onRestartServer) +Views.SettingsScreen --> Views.SettingItem : Device Name (GradientButton -> showDialog=true) +Views.SettingsScreen --> Views.ChangeDeviceNameDialog : if(showDialog) + +Views.SettingsScreen --> Views.SectionHeader : "Receive" +Views.SettingsScreen --> Views.SettingItem : Auto Finish (Switch) +Views.SettingsScreen --> Views.SettingItem : Save to folder (GradientButton -> directoryLauncher) + +Views.SettingsScreen --> Views.SectionHeader : "Concurrency"\n(only if SDK< R) +Views.SettingsScreen --> Views.SettingItem : Reset (GradientLongButton) + +' ---- Callback behaviors ---- +note bottom of Views.SettingsScreen +Callbacks + persistence: + +Language: +- viewModel.saveLang(code) +- onLanguageChange(code) + +Theme: +- viewModel.saveTheme(theme) +- onThemeChange(theme) + +Device Name: +- showDialog -> ChangeDeviceNameDialog +- confirm: + viewModel.saveDeviceName(newName) + onDeviceNameChange(newName) + +Auto Finish Switch: +- viewModel.saveAutoFinish(isChecked) +- onAutoFinishChange(isChecked) + +Save to Folder: +- OpenDocumentTree returns Uri? +- if uri!=null: + takePersistableUriPermission(READ|WRITE) + viewModel.saveSaveToFolder(uri.toString()) + onSaveToFolderChange(uri.toString()) +- else: + Toast("No directory selected") + +Concurrency reset (SDK< R): +- viewModel.updateConcurrencySettings(false, true) +- Toast("Reset ... Unknown") +end note + +' ---- Folder name display logic ---- +note right of Views.SettingItem +folderNameToShow: +if saveToFolder startsWith "content://": + Uri.decode(value).split(":").lastOrNull() ?: "Unknown" +else: + value.split("/").lastOrNull() ?: "Unknown" +end note + +@enduml