diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 000000000..a29d8bf1e --- /dev/null +++ b/.github/README.md @@ -0,0 +1,37 @@ +# Mesh CI + +--- + +## NOTES + +- To run the orchestrator to test on branches outside of main, run ```gh workflow run reporting_orchestrator.yml --ref feature/ras-cicd```, replacing "feature/ras-cicd" whichever branch you wish to run the workflow against. +- Super-Linter summary can only be added on pushes and PRs, not manual runs. Super-Linter is still running, you just need to dig into the workflow details to view useful results. + +--- + +## Pipeline Flow + +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) + +- This Section Needs updating + +--- + +## General TODO + +### Ongoing +- Address linting flags + +### Backlog + +- seems Very complex, but find a way to generate the site for any commit in any branch, not just most recent in main/cicd testing/most recent pr +- Add script to preview to change iFrame title and onscreen title + pass/fail indicator +- For orchestrator + - add setting on manual run whether to commit anything/deploy to pages +- Address all remaining errors and warnings in pipe +- Add trigger for Build APKs that specifically builds them with no TTL (default 1 day rn) when run from a PR merge +- Increase test coverage diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml new file mode 100644 index 000000000..cb41d4883 --- /dev/null +++ b/.github/linters/.markdown-lint.yml @@ -0,0 +1,7 @@ +--- +# MD013/line-length - Line length +MD013: + # Number of characters, default is 80 + line_length: 9999 + # check code blocks? + code_blocks: false \ No newline at end of file diff --git a/.github/linters/.yaml-lint.yml b/.github/linters/.yaml-lint.yml new file mode 100644 index 000000000..030c37f04 --- /dev/null +++ b/.github/linters/.yaml-lint.yml @@ -0,0 +1,53 @@ +--- +########################################### +# These are the rules used for # +# linting all the yaml files in the stack # +# NOTE: # +# You can disable line with: # +# # yamllint disable-line # +########################################### +rules: + braces: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + brackets: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + colons: + level: warning + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: warning + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: disable + comments-indentation: disable + document-end: disable + document-start: disable + empty-lines: + level: warning + max: 2 + max-start: 0 + max-end: 0 + hyphens: + level: warning + max-spaces-after: 1 + indentation: + level: warning + spaces: consistent + indent-sequences: true + check-multi-line-strings: false + key-duplicates: enable + line-length: disable + new-line-at-end-of-file: disable + new-lines: + type: unix + trailing-spaces: disable \ No newline at end of file diff --git a/.github/workflows/build_artifacts.yml b/.github/workflows/build_artifacts.yml new file mode 100644 index 000000000..74ef80351 --- /dev/null +++ b/.github/workflows/build_artifacts.yml @@ -0,0 +1,77 @@ +# 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 when called by another workflow + workflow_call: + inputs: + skip_commit: + description: "Skip committing reports" + required: false + type: boolean + default: false + + # 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 }}-Build APKs + 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 + + - name: Check Out Code + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "17" + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + add-job-summary: never + + - name: Build with Gradle Wrapper + run: ./gradlew assemble # use assemble because build runs tests, even though a different part of the system handles that + + - name: Upload App Debug APK + uses: actions/upload-artifact@v6 + with: + name: app-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk + retention-days: 1 + + - name: Upload App Release APK + uses: actions/upload-artifact@v6 + with: + name: app-release-apk + path: app/build/outputs/apk/release/app-release-unsigned.apk + retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/build_documentation.yml b/.github/workflows/build_documentation.yml new file mode 100644 index 000000000..b07a92be1 --- /dev/null +++ b/.github/workflows/build_documentation.yml @@ -0,0 +1,78 @@ +name: Build Dokka Docs +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run when called by another workflow + workflow_call: + inputs: + skip_commit: + description: "Skip committing reports" + required: false + type: boolean + default: false + + # 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 }}-Build Dokka Docs + cancel-in-progress: true + +permissions: read-all + +# adapted from https://mskelton.medium.com/auto-formatting-code-using-prettier-and-github-actions-ed458f58b7df +jobs: + call-dokka: + name: Call Dokka Html + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check Out Code + uses: actions/checkout@v6 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "17" + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + add-job-summary: never + + - name: Generate Dokka Pages + run: ./gradlew dokkaGenerate + continue-on-error: true + + - name: Configure Git Identity (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Copy Documentation to Viewing Directory (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + mkdir ./build_reports/documentation + cp -r ./app/build/dokka/html/ ./build_reports/documentation + git add --force ./build_reports/ + git commit -m "Relocate documentation for viewing" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Documentation Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: dokka_documentation_artifact + path: ./app/build/dokka/html + retention-days: 1 \ No newline at end of file diff --git a/.github/workflows/call_gradle_lint.yml b/.github/workflows/call_gradle_lint.yml new file mode 100644 index 000000000..9dbb9894b --- /dev/null +++ b/.github/workflows/call_gradle_lint.yml @@ -0,0 +1,101 @@ +name: Lint Kotlin Code (Gradle Linter) +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run when called by another workflow + workflow_call: + inputs: + skip_commit: + description: "Skip committing reports" + required: false + type: boolean + default: false + + # 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 }}-Lint Kotlin Code (Gradle Linter) + cancel-in-progress: true + +permissions: read-all + +# adapted from https://mskelton.medium.com/auto-formatting-code-using-prettier-and-github-actions-ed458f58b7df +jobs: + call-gradle-linter: + name: Call Gradle Linter + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check Out Code + uses: actions/checkout@v6 + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "17" + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + add-job-summary: never + + - name: Call Debug and Release Linters + # ostensibly, ./gradlew lint should run both, but in practice on my (Thalia Wood's), it only runs the Debug linter + run: | + set -o pipefail + output1=$(./gradlew lintDebug) + output2=$(./gradlew lintRelease) + echo "$output1" + echo "---" + echo "$output2" + if [[ (echo "$output1" | grep -Ezq " Task :app:compileDebugKotlin\nw: file:") || (echo "$output2" | grep -Ezq " Task :app:compileReleaseKotlin\nw: file:") ]]; then + echo "Linting warnings detected. View details at https://grey-box.github.io/Project-Mesh/ once the workflow is finished." + exit 1 + else + echo "Linting returned clean" + fi + continue-on-error: true + + - name: Configure Git Identity (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Copy Linting Reports to Viewing Directory (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + mkdir ./build_reports/lint_results_debug + cp ./app/build/reports/lint-results-debug.html ./build_reports/lint_report_debug/lint_report_debug.html + mkdir ./build_reports/lint_results_release + cp ./app/build/reports/lint-results-release.html ./build_reports/lint_report_release/lint_report_release.html + git add --force ./build_reports/ + git commit -m "Relocate linting report for viewing" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Lint Debug Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: lint_report_debug_artifact + path: ./app/build/reports/lint-results-debug.html + retention-days: 1 + + - name: Upload Lint Release Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: lint_report_release_artifact + path: ./app/build/reports/lint-results-release.html + retention-days: 1 diff --git a/.github/workflows/call_super_lint.yml b/.github/workflows/call_super_lint.yml new file mode 100644 index 000000000..c2d79dcc4 --- /dev/null +++ b/.github/workflows/call_super_lint.yml @@ -0,0 +1,36 @@ +name: Lint Code Base (Super-Linter) +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run when called by another workflow + workflow_call: + inputs: + skip_commit: + description: "Skip committing reports" + required: false + type: boolean + default: false + + # 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 }}-Lint Code Base (Super-Linter) + cancel-in-progress: true + +permissions: read-all + +jobs: + call-super-linter: + name: Call Super-Linter + permissions: + contents: read + statuses: write + + uses: ./.github/workflows/super_lint.yml \ No newline at end of file diff --git a/.github/workflows/reporting_orchestrator.yml b/.github/workflows/reporting_orchestrator.yml new file mode 100644 index 000000000..84a15ccfa --- /dev/null +++ b/.github/workflows/reporting_orchestrator.yml @@ -0,0 +1,197 @@ +name: Compile Build Reports +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: + lint-kotlin: + name: Lint Kotlin (Gradle Linter) + uses: ./.github/workflows/call_gradle_lint.yml + with: + skip_commit: true + + test-and-report-coverage: + name: Test and Report Coverage + uses: ./.github/workflows/test_and_report_coverage.yml + with: + skip_commit: true + + build-documentation: + name: Build Dokka Documentation + uses: ./.github/workflows/build_documentation.yml + with: + skip_commit: true + + upload-apks: + name: Upload APKs + uses: ./.github/workflows/build_artifacts.yml + + submit-dependency-graph: + name: Submit Dependency Graph + uses: ./.github/workflows/submit_dependency_graph.yml + + lint-codebase: + name: Lint Code Base (Super-Linter) + uses: ./.github/workflows/call_super_lint.yml + + + finalize-report-viewer: + name: Finalize Report Viewer + needs: [lint-kotlin, test-and-report-coverage, build-documentation] + if: always() + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + id-token: write + pages: write + environment: + name: github-pages + url: ${{ steps.set-pages-link.outputs.pages-link }} + steps: + - name: Check Out Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # necessary to allow branch switching + + # - name: Ensure Report Dir is Empty # add files NOT to delete each run to the list below + # run: | + # export GLOBIGNORE="./build_reports/index.html:./build_reports/styles.css:./build_reports/README.md:./build_reports/.gitignore:./build_reports/include_html.js" + # rm -rf ./build_reports/* + # unset GLOBIGNORE + + - name: Create Clean Build-Reports Directory + run: | + rm -rf ./build_reports 2> /dev/null + mkdir ./build_reports + + + - name: Download All Artifacts + uses: actions/download-artifact@v8 + with: + path: ./build_reports/ + # TODO: edit to never pull APKs + + - name: Relocate CI Artifacts to Dirs + run: | + rm -rf ./build_reports/app-debug-apk 2> /dev/null + rm -rf ./build_reports/app-release-apk 2> /dev/null + + mkdir ./build_reports/coverage_report + cp -a ./build_reports/coverage_report_artifact/. ./build_reports/coverage_report/ + rm -r ./build_reports/coverage_report_artifact + + mkdir ./build_reports/lint_report_debug + cp ./build_reports/lint_report_debug_artifact/lint-results-debug.html ./build_reports/lint_report_debug/lint_report_debug.html + rm -r ./build_reports/lint_report_debug_artifact + + ls ./build_reports + echo "----" + ls ./build_reports/lint_report_debug + + sed -i 's/1px; 1px;/1px 1px;/g' ./build_reports/lint_report_debug/lint_report_debug.html + + mkdir ./build_reports/lint_report_release + cp ./build_reports/lint_report_release_artifact/lint-results-release.html ./build_reports/lint_report_release/lint_report_release.html + rm -r ./build_reports/lint_report_release_artifact + + ls ./build_reports + echo "----" + ls ./build_reports/lint_report_release + + sed -i 's/1px; 1px;/1px 1px;/g' ./build_reports/lint_report_release/lint_report_release.html + + mkdir ./build_reports/test_report_debug + cp -a ./build_reports/test_report_debug_artifact/. ./build_reports/test_report_debug/ + rm -r ./build_reports/test_report_debug_artifact + + mkdir ./build_reports/test_report_release + cp -a ./build_reports/test_report_release_artifact/. ./build_reports/test_report_release/ + rm -r ./build_reports/test_report_release_artifact + + mkdir ./build_reports/documentation + cp -a ./build_reports/dokka_documentation_artifact/. ./build_reports/documentation/ + rm -r ./build_reports/dokka_documentation_artifact + + echo "----" + ls ./build_reports + echo "----" + ls ./build_reports/documentation + + - name: Write Timestamp + run: | + mkdir ./build_reports/footer_timestamp + echo "Last updated at: " > ./build_reports/footer_timestamp/timestamp.txt + date +"%F %T %Z" >> ./build_reports/footer_timestamp/timestamp.txt + + - name: Configure Git Identity + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Commit Reports to Viewing Directory + # PRs run workflows out of a temp branch instead of the calling branch, so they need to create a temp branch to host the commit for build reports + run: | + git rm -r --cached --ignore-unmatch ./build_reports + git add ./build_reports + git status + git commit -m "Local commit for cherrypick" + if [ "${{ github.event_name }}" = "push" ]; then + echo "Triggered by Push, standard case" + git switch gh-pages + git cherry-pick -X theirs ${{ github.ref_name }} + git push + elif [ "${{ github.event_name }}" = "pull_request" ]; then + echo "Triggered by PR, creating temp branch (will not outlive workflow)" + git branch temp_for_cherrypick "$(git rev-parse --short HEAD)" + git switch gh-pages + git cherry-pick -X theirs temp_for_cherrypick + git push + else + echo "Triggered by: ${{ github.event_name }}. This is unhandled." + exit 2 + fi + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # - name: Upload Reports Viewer Directory to Pages + # uses: actions/upload-pages-artifact@v3 + # with: + # path: ./build_reports + + # - name: Deploy to GitHub Pages + # id: deployment + # uses: actions/deploy-pages@v4 + + - name: Define Pages Link on Success + id: set-pages-link + run: | + echo "pages-link=https://grey-box.github.io/Project-Mesh/" >> $GITHUB_OUTPUT + + - name: Link Pages in Build Summary + run: | + echo "All reports can be viewed at ${{ steps.set-pages-link.outputs.pages-link }} about 30s after this workflow is completed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + diff --git a/.github/workflows/submit_dependency_graph.yml b/.github/workflows/submit_dependency_graph.yml new file mode 100644 index 000000000..7599798ba --- /dev/null +++ b/.github/workflows/submit_dependency_graph.yml @@ -0,0 +1,43 @@ +name: Submit Dependency Graph +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run when called by another workflow + workflow_call: + + # 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 }}-Submit Dependency Graph + cancel-in-progress: true + +jobs: + dependency-submission: + name: Submit Dependencies + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check Out Code + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: "17" + 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@v5 + with: + add-job-summary: never diff --git a/.github/workflows/super_lint.yml b/.github/workflows/super_lint.yml new file mode 100644 index 000000000..8002b230c --- /dev/null +++ b/.github/workflows/super_lint.yml @@ -0,0 +1,214 @@ +--- +# original template from: https://github.com/bretfisher/super-linter-workflow/blob/main/.github/workflows/reusable-super-linter.yaml + +########################### +########################### +## Linter GitHub Actions ## +########################### +########################### +name: Super Lint +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +# +# Documentation: +# https://help.github.com/en/articles/workflow-syntax-for-github-actions +# + +on: + # run when called by another workflow + workflow_call: + inputs: + devops-only: + description: For a DevOps-focused repository. Prevents some code-language linters from running + required: false + type: boolean + default: false + filter-regex-exclude: + description: A regex to exclude files from linting + required: false + type: string + + # run on demand + workflow_dispatch: + inputs: + custom-run-name: + description: "Custom name for this Actions run" + required: false + type: string + devops-only: + description: For a DevOps-focused repository. Prevents some code-language linters from running + required: false + type: boolean + default: false + filter-regex-exclude: + description: A regex to exclude files from linting + required: false + type: string + +permissions: + contents: read # git permissions to repo pull/push + statuses: write # read/write to repo custom statuses and checks + +jobs: + super-lint: + + name: Super-Linter + + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + # Full git history is needed to get a proper list of changed files within super-linter + fetch-depth: 0 + + ############################# + # custom DEFAULT_BRANCH for repos where PR target isn't always main/master + ############################# + - name: Set DEFAULT_BRANCH to PR target + # if base_ref has a value, this is a PR + # we save the PR target branch name to a variable for use in linter config + # we pass string between job steps by echoing to $GITHUB_ENV, making it available in $env later + if: ${{ github.base_ref != '' }} + run: | + # shellcheck disable=2086 + echo "DEFAULT_BRANCH=${{ github.base_ref }}" >> $GITHUB_ENV + echo "this is a PR branch. Let's only lint the files that are changed against the target branch '${{ github.base_ref }}'" + + - name: Set DEFAULT_BRANCH to current branch + # if base_ref has no value, this is just a commit on a branch + # we need to strip refs/heads from github.ref to find the current branch name + # then save the current branch name to a variable for use in linter config later + # we pass strings between job steps by echoing to $GITHUB_ENV, making it available in $env later + if: ${{ github.base_ref == '' }} + run: | + # shellcheck disable=2086 + echo "DEFAULT_BRANCH=$(echo '${{ github.ref }}' | sed 's/refs\/heads\///')" >> $GITHUB_ENV + echo "this is just a branch push, not a PR." + + # used as a debug step to ensure we're only linting all files on release branches + - name: Are we linting all files? + run: | + echo VALIDATE_ALL_CODEBASE=${{ !contains(github.event_name, 'pull_request') }} + + # customize excluded paths and files with regex + - name: FILTER_REGEX_EXCLUDE + if: ${{ inputs.filter-regex-exclude }} + run: | + # shellcheck disable=2086 + { + echo "FILTER_REGEX_EXCLUDE=${{ inputs.filter-regex-exclude }}" >> $GITHUB_ENV + } + + # disable non-DevOps focused linters that might run on sample code or 3rd party code + # these env's will get pass to the next step + - name: Disable non-DevOps linters + if: ${{ inputs.devops-only == true }} + run: | + # shellcheck disable=2086 + { + echo "VALIDATE_CSS=false"; + echo "VALIDATE_HTML=false"; + echo "VALIDATE_JAVASCRIPT_ES=false"; + echo "VALIDATE_TYPESCRIPT_ES=false"; + echo "VALIDATE_TYPESCRIPT_STANDARD=false"; + echo "VALIDATE_JAVASCRIPT_STANDARD=false"; + echo "VALIDATE_PYTHON_MYPY=false"; + echo "VALIDATE_PYTHON_BLACK=false"; + echo "VALIDATE_PYTHON_FLAKE8=false"; + echo "VALIDATE_PYTHON_ISORT=false"; + echo "VALIDATE_RUBY=false"; + echo "VALIDATE_PHP=false"; + echo "VALIDATE_CSHARP=false"; + + } >> $GITHUB_ENV + + ############################# + # Run many Linters against changed files on PRs, and ALL files on commit to release branch + ############################# + # https://github.com/marketplace/actions/super-linter + - name: Lint Code Base + uses: super-linter/super-linter@v8.2.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # by default super-linter assumes our repo default branch doesn't change + # and it also assumes our PRs are always against that default branch + # for multi-trunk (releases) repos, this get the base branch from the previous steps + # see issue https://github.com/github/super-linter/issues/1123 + DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }} + # setting this to false means that only changed files will be scanned in each commit + VALIDATE_ALL_CODEBASE: ${{ !contains(github.event_name, 'pull_request') }} + # turn off dockerfilelint, as its a dead project + # https://github.com/replicatedhq/dockerfilelint/issues/169 + # hadolint will still run and is sufficient (no need for two linters) + VALIDATE_DOCKERFILE: false + # turn off JSCPD copy/paste detection, which results in lots of results for examples and devops repos + VALIDATE_JSCPD: false + # turn off shfmt shell formatter as we already have shellcheck + VALIDATE_SHELL_SHFMT: false + # editorconfig is great, but... + # editorconfig-linter is rather generic and file-specific linters are better + # turn off editorconfig-checker, which flags too many false positives + VALIDATE_EDITORCONFIG: false + # prevent Kubernetes CRD API's from causing kubeval to fail + # also change schema location to an up-to-date list + # https://github.com/yannh/kubernetes-json-schema/#kubeval + KUBERNETES_KUBEVAL_OPTIONS: --ignore-missing-schemas --schema-location https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/ + + # default all linters to fix mode + FIX_ANSIBLE: true + FIX_BIOME_FORMAT: true + FIX_BIOME_LINT: true + FIX_CLANG_FORMAT: true + FIX_CSHARP: true + FIX_CSS_PRETTIER: true + FIX_CSS: true + FIX_DOTNET_SLN_FORMAT_ANALYZERS: true + FIX_DOTNET_SLN_FORMAT_STYLE: true + FIX_DOTNET_SLN_FORMAT_WHITESPACE: true + FIX_ENV: true + FIX_GITHUB_ACTIONS_ZIZMOR: true + FIX_GO_MODULES: true + FIX_GO: true + FIX_GOOGLE_JAVA_FORMAT: true + FIX_GRAPHQL_PRETTIER: true + FIX_GROOVY: true + FIX_HTML_PRETTIER: true + FIX_JAVASCRIPT_ES: true + FIX_JAVASCRIPT_PRETTIER: true + FIX_JSON_PRETTIER: true + FIX_JSON: true + FIX_JSONC: true + FIX_JSONC_PRETTIER: true + FIX_JSX_PRETTIER: true + FIX_JSX: true + FIX_JUPYTER_NBQA_BLACK: true + FIX_JUPYTER_NBQA_ISORT: true + FIX_JUPYTER_NBQA_RUFF: true + FIX_KOTLIN: true + FIX_MARKDOWN_PRETTIER: true + FIX_MARKDOWN: true + FIX_NATURAL_LANGUAGE: true + FIX_POWERSHELL: true + FIX_PROTOBUF: true + FIX_PYTHON_BLACK: true + FIX_PYTHON_ISORT: true + FIX_PYTHON_RUFF: true + FIX_PYTHON_RUFF_FORMAT: true + FIX_RUBY: true + FIX_RUST_2015: true + FIX_RUST_2018: true + FIX_RUST_2021: true + FIX_RUST_CLIPPY: true + FIX_SCALAFMT: true + FIX_SHELL_SHFMT: false # see note above about removing validation + FIX_SNAKEMAKE_SNAKEFMT: true + FIX_SQLFLUFF: true + FIX_TERRAFORM_FMT: true + FIX_TSX: true + FIX_TYPESCRIPT_ES: true + FIX_TYPESCRIPT_PRETTIER: true + FIX_VUE: true + FIX_VUE_PRETTIER: true + FIX_YAML_PRETTIER: true \ No newline at end of file diff --git a/.github/workflows/test_and_report_coverage.yml b/.github/workflows/test_and_report_coverage.yml new file mode 100644 index 000000000..645f5f741 --- /dev/null +++ b/.github/workflows/test_and_report_coverage.yml @@ -0,0 +1,152 @@ +name: Measure Test Coverage +run-name: ${{ github.event.inputs.custom_run_name || github.workflow }} + +on: + # run when called by another workflow + workflow_call: + inputs: + skip_commit: + description: "Skip committing reports" + required: false + type: boolean + default: false + + # 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 }}-Measure Test Coverage + cancel-in-progress: true + +jobs: + test_and_report_coverage: + name: Test and Report Coverage + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check Out Code + uses: actions/checkout@v6 + + - name: Define Coverage Minimums + run: | + echo "min-coverage-overall=80" >> $GITHUB_ENV + echo "min-coverage-changed-files=80" >> $GITHUB_ENV + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: "temurin" + java-version: "17" + + - name: Set Up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + add-job-summary: never + + - name: Generate Kover Coverage Report + id: koverXmlReport + run: ./gradlew koverXmlReport --continue + continue-on-error: true + + # if all tests pass, no report is generated, which errors the orchestrator. Generate a simple passing notification in the same location. + - name: Insert Passing Report (only on test pass) + if: steps.koverXmlReport.outcome == 'success' + run: | + mkdir ./app/build/reports/kover/html + echo "

All tests pass! (or tests not found :/)

" > ./app/build/reports/kover/html/index.html + + - name: Download Kover CLI (only on test failure) + if: steps.koverXmlReport.outcome == 'failure' + run: wget -O kover-cli.jar https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kover-cli/0.9.3/kover-cli-0.9.3.jar + # ensure the versions in the link match the kover version in the top-level build.gradle.kts file + + - name: Format Kover XML Coverage Report Via CLI (only on test failure) + if: steps.koverXmlReport.outcome == 'failure' + run: java -jar kover-cli.jar report ./app/build/kover/bin-reports/testDebugUnitTest.ic --classfiles ./app/build/tmp/kotlin-classes --src ./app/src/main/java --xml ./app/build/reports/kover/report.xml + + - name: Format Kover HTML Coverage Report Via CLI (only on test failure) + if: steps.koverXmlReport.outcome == 'failure' + run: java -jar kover-cli.jar report ./app/build/kover/bin-reports/testDebugUnitTest.ic --classfiles ./app/build/tmp/kotlin-classes --src ./app/src/main/java --html ./app/build/reports/kover/html/ + + - name: Add Coverage Report to PR (if triggered via valid event) + # conditional for graceful handling of Kover limitations + id: kover + if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + 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: Configure Git Identity (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + + - name: Copy Coverage Report & Test Outputs to Viewing Directory (if called individually) + if: ${{ inputs.skip_commit != true }} + run: | + cp -r ./app/build/reports/kover/html/ ./build_reports/coverage_report + cp -r ./app/build/reports/tests/testDebugUnitTest ./build_reports/test_report_debug + cp -r ./app/build/reports/tests/testReleaseUnitTest ./build_reports/test_report_release + git add --force ./build_reports/ + git commit -m "Relocate coverage report for viewing" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Coverage Report Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: coverage_report_artifact + path: ./app/build/reports/kover/html + retention-days: 1 + + - name: Upload Test Debug Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: test_report_debug_artifact + path: ./app/build/reports/tests/testDebugUnitTest + retention-days: 1 + + - name: Upload Test Release Artifact (if called by orchestrator) + if: ${{ inputs.skip_commit == true }} + uses: actions/upload-artifact@v6 + with: + name: test_report_release_artifact + path: ./app/build/reports/tests/testReleaseUnitTest + retention-days: 1 + + - name: Add 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 "Full coverage report can be viewed at https://grey-box.github.io/Project-Mesh/" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Verify Test/Build Outcome + if: steps.koverXmlReport.outcome == 'failure' + run: | + echo "Tests (or build) failed, marking workflow as failed" + exit 1 diff --git a/.gitignore b/.gitignore index 6e1af9e57..71daa6324 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ kotlin-ide/ .idea/ .aider* .env +.idea/misc.xml +.vscode 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 967417e76..61e4dba9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,8 @@ 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" + id("org.jetbrains.dokka") version "2.2.0-Beta" } android { @@ -39,6 +41,10 @@ android { kotlinOptions { jvmTarget = "1.8" } + lint { + // affects gradle linter + disable.add("UnusedResources") + } buildFeatures { compose = true } @@ -60,89 +66,89 @@ android { } dependencies { - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) + // =============================== + // General + // =============================== + implementation(libs.accompanist.permissions) + implementation(libs.acra.dialog) + implementation(libs.acra.http) implementation(libs.androidx.activity.compose) - implementation("ch.acra:acra-http:5.11.0") - implementation("ch.acra:acra-dialog:5.11.0") - implementation(platform(libs.androidx.compose.bom)) - implementation("androidx.compose.material3:material3:1.2.1") - implementation("androidx.compose.material:material-icons-core:1.6.8") - implementation("androidx.compose.material:material-icons-extended-android:1.6.8") - implementation(libs.androidx.foundation) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) + implementation(libs.androidx.activity) + implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) - implementation(libs.material) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.datastore.core.v111) + implementation(libs.androidx.datastore.preferences.core) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.foundation) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.datastore.preferences.core) - implementation(libs.androidx.activity) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.android) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.ui) + implementation(libs.coil.compose) + implementation(libs.compose.qrpainter) + implementation(libs.gson) // for crash screen + implementation(libs.ipaddress) + implementation(libs.jetbrains.kotlinx.serialization.json) // For JSON serialization + implementation(libs.material) + implementation(libs.meshrabiya) + implementation(libs.nanohttp) + implementation(libs.okhttp) + implementation(libs.zxing.android.embedded) + implementation(platform(libs.androidx.compose.bom)) + + + // =============================== + // Unit testing (JVM) deps added + // =============================== + testImplementation(libs.androidx.test.core) + testImplementation(libs.jetbrains.kotlinx.coroutines.test) testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.turbine) + androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) + androidTestImplementation(platform(libs.androidx.compose.bom)) + debugImplementation(libs.androidx.ui.test.manifest) - implementation("com.github.UstadMobile.Meshrabiya:lib-meshrabiya:0.1d10-snapshot") - implementation("com.github.seancfoley:ipaddress:5.3.3") - implementation("com.squareup.okhttp3:okhttp:4.10.0") - implementation("org.nanohttpd:nanohttpd:2.3.1") - implementation (libs.material) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") - implementation("com.github.yveskalume:compose-qrpainter:0.0.1") - implementation("com.journeyapps:zxing-android-embedded:4.3.0") - implementation(libs.androidx.appcompat) - implementation ("io.coil-kt:coil-compose:1.4.0") - implementation("androidx.compose.material3:material3:1.2.1") - implementation("androidx.navigation:navigation-compose:2.7.7") - implementation("com.google.accompanist:accompanist-permissions:0.31.1-alpha") - // Core Kodein DI dependency + debugImplementation(libs.androidx.ui.tooling) + + // =============================== + // Kodein + // =============================== // For Android-specific features - implementation ("org.kodein.di:kodein-di-framework-android-x:7.20.2") + implementation (libs.kodein.di.framework.android.x) // For Jetpack Compose support - implementation ("org.kodein.di:kodein-di-framework-compose:7.20.2") + implementation (libs.kodein.di.framework.compose) - val room_version = "2.6.1" - implementation("androidx.room:room-runtime:$room_version") - annotationProcessor("androidx.room:room-compiler:$room_version") + // =============================== + // Room + // =============================== + annotationProcessor(libs.androidx.room.compiler) + implementation(libs.androidx.room.runtime) // To use Kotlin annotation processing tool (kapt) // kapt("androidx.room:room-compiler:$room_version") // To use Kotlin Symbol Processing (KSP) - ksp("androidx.room:room-compiler:$room_version") - - // optional - Kotlin Extensions and Coroutines support for Room - implementation("androidx.room:room-ktx:$room_version") - - // optional - RxJava2 support for Room - implementation("androidx.room:room-rxjava2:$room_version") - - // optional - RxJava3 support for Room - implementation("androidx.room:room-rxjava3:$room_version") - - // optional - Guava support for Room, including Optional and ListenableFuture - implementation("androidx.room:room-guava:$room_version") - - // optional - Test helpers - testImplementation("androidx.room:room-testing:$room_version") - - // optional - Paging 3 Integration - implementation("androidx.room:room-paging:$room_version") - - // for crash scren - implementation("com.google.code.gson:gson:2.10.1") - - // For JSON serialisation - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") -} \ No newline at end of file + ksp(libs.androidx.room.compiler) + + implementation(libs.androidx.room.guava) // optional - Guava support for Room, including Optional and ListenableFuture + implementation(libs.androidx.room.ktx) // optional - Kotlin Extensions and Coroutines support for Room + implementation(libs.androidx.room.paging) // optional - Paging 3 Integration + implementation(libs.androidx.room.rxjava2) // optional - RxJava2 support for Room + implementation(libs.androidx.room.rxjava3) // optional - RxJava3 support for Room + testImplementation(libs.androidx.room.testing) // optional - Test helpers +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a5ed25801..8039c6669 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ - - + + @@ -24,24 +26,27 @@ + android:name="android.hardware.camera" + android:required="false" + /> - - + android:name="android.permission.NEARBY_WIFI_DEVICES" + android:usesPermissionFlags="neverForLocation" + /> + + - + - + @@ -55,26 +60,28 @@ + android:name=".GlobalApp" + android:allowBackup="true" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/backup_rules" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:usesCleartextTraffic="true" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.AppCompat" + android:hardwareAccelerated="true" + tools:targetApi="31" + > + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.ProjectMesh.Launcher" + > @@ -82,21 +89,28 @@ + android:name="androidx.core.content.FileProvider" + android:authorities="com.greybox.projectmesh.fileprovider" + android:grantUriPermissions="true" + android:exported="false" + > + android:name="android.support.FILE_PROVIDER_PATHS" + 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/GlobalApp.kt b/app/src/main/java/com/greybox/projectmesh/GlobalApp.kt index 7f22e1494..0156a677c 100644 --- a/app/src/main/java/com/greybox/projectmesh/GlobalApp.kt +++ b/app/src/main/java/com/greybox/projectmesh/GlobalApp.kt @@ -5,7 +5,6 @@ import android.app.Application import android.content.Context import android.content.SharedPreferences import android.os.Build -import android.os.Environment import android.util.Log import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -19,11 +18,9 @@ import com.greybox.projectmesh.extension.networkDataStore import com.greybox.projectmesh.server.AppServer import com.ustadmobile.meshrabiya.ext.addressToDotNotation import com.ustadmobile.meshrabiya.ext.asInetAddress -import com.ustadmobile.meshrabiya.ext.requireAddressAsInt import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode import com.ustadmobile.meshrabiya.vnet.randomApipaAddr import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -38,8 +35,6 @@ import org.kodein.di.singleton import java.io.File import java.net.InetAddress import java.time.Duration -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap import com.greybox.projectmesh.user.UserRepository import com.greybox.projectmesh.messaging.data.entities.Message diff --git a/app/src/main/java/com/greybox/projectmesh/MainActivity.kt b/app/src/main/java/com/greybox/projectmesh/MainActivity.kt index 468680675..2f464032c 100644 --- a/app/src/main/java/com/greybox/projectmesh/MainActivity.kt +++ b/app/src/main/java/com/greybox/projectmesh/MainActivity.kt @@ -1,95 +1,76 @@ package com.greybox.projectmesh -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment -import android.os.PowerManager -import android.provider.Settings import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.greybox.projectmesh.debug.CrashHandler import com.greybox.projectmesh.debug.CrashScreenActivity -import com.greybox.projectmesh.navigation.BottomNavItem +import com.greybox.projectmesh.messaging.data.entities.Conversation +import com.greybox.projectmesh.messaging.ui.screens.ChatScreen +import com.greybox.projectmesh.messaging.ui.screens.ConversationsHomeScreen +import com.greybox.projectmesh.messaging.ui.viewmodels.ChatScreenViewModel import com.greybox.projectmesh.navigation.BottomNavigationBar +import com.greybox.projectmesh.navigation.BottomNavItem import com.greybox.projectmesh.server.AppServer +import com.greybox.projectmesh.testing.TestDeviceService import com.greybox.projectmesh.ui.theme.AppTheme import com.greybox.projectmesh.ui.theme.ProjectMeshTheme +import com.greybox.projectmesh.user.UserRepository import com.greybox.projectmesh.viewModel.SharedUriViewModel -import com.greybox.projectmesh.messaging.ui.screens.ChatScreen import com.greybox.projectmesh.views.HomeScreen -import com.greybox.projectmesh.views.SettingsScreen +import com.greybox.projectmesh.views.LogScreen import com.greybox.projectmesh.views.NetworkScreen +import com.greybox.projectmesh.views.OnboardingScreen import com.greybox.projectmesh.views.PingScreen import com.greybox.projectmesh.views.ReceiveScreen import com.greybox.projectmesh.views.SelectDestNodeScreen import com.greybox.projectmesh.views.SendScreen -import com.greybox.projectmesh.views.OnboardingScreen -import com.greybox.projectmesh.testing.TestDeviceService -import org.kodein.di.DI -import org.kodein.di.DIAware -import org.kodein.di.android.closestDI -import org.kodein.di.compose.withDI -import org.kodein.di.instance +import com.greybox.projectmesh.views.SettingsScreen import java.io.File -import java.util.Locale import java.net.InetAddress -import com.greybox.projectmesh.messaging.ui.screens.ChatNodeListScreen -import com.greybox.projectmesh.messaging.ui.screens.ConversationsHomeScreen -import com.greybox.projectmesh.user.UserRepository +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.remember -import kotlinx.coroutines.launch -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalSavedStateRegistryOwner -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch -import com.greybox.projectmesh.messaging.data.entities.Conversation -import com.greybox.projectmesh.messaging.ui.viewmodels.ChatScreenViewModel -import com.greybox.projectmesh.views.LogScreen +import org.kodein.di.android.closestDI +import org.kodein.di.compose.withDI +import org.kodein.di.DI +import org.kodein.di.DIAware +import org.kodein.di.instance import com.greybox.projectmesh.views.RequestPermissionsScreen @@ -129,7 +110,7 @@ class MainActivity : ComponentActivity(), DIAware { mutableStateOf(settingPref.getString( "language", "en") ?: "en") } - var restartServerKey by remember {mutableStateOf(0)} + var restartServerKey by remember {mutableIntStateOf(0)} var deviceName by remember { mutableStateOf(settingPref.getString("device_name", Build.MODEL) ?: Build.MODEL) } diff --git a/app/src/main/java/com/greybox/projectmesh/components/WifiConnection.kt b/app/src/main/java/com/greybox/projectmesh/components/WifiConnection.kt index 864274d4d..a40fc29d4 100644 --- a/app/src/main/java/com/greybox/projectmesh/components/WifiConnection.kt +++ b/app/src/main/java/com/greybox/projectmesh/components/WifiConnection.kt @@ -28,7 +28,6 @@ import com.ustadmobile.meshrabiya.vnet.wifi.WifiConnectException import java.util.regex.Pattern import android.util.Log - // This File is to pre-check the wifi connection, reusing from Meshrabiya test app /* WorkFlow: @@ -41,24 +40,62 @@ WorkFlow: 4. It handles the result (successful connection or error) and updates the UI as needed. */ -fun interface ConnectWifiLauncher{ +/** + * Functional interface representing a launcher for Wi-Fi connections. + */ +fun interface ConnectWifiLauncher { + /** + * Launch a connection attempt with the specified Wi-Fi configuration. + * + * @param config The Wi-Fi configuration to connect to. + */ fun launch(config: WifiConnectConfig) } +/** + * Represents a request to connect to a Wi-Fi network. + * + * @property receivedTime The timestamp when the request was created. + * @property connectConfig The configuration of the Wi-Fi network to connect to. + */ data class ConnectRequest( val receivedTime: Long = 0, val connectConfig: WifiConnectConfig, ) +/** + * Result of a Wi-Fi connection attempt. + * + * @property hotspotConfig The configuration of the hotspot connected to, or null if failed. + * @property exception Any exception that occurred during connection, or null if successful. + * @property isWifiConnected True if the connection was successful, false otherwise. + */ data class ConnectWifiLauncherResult( val hotspotConfig: WifiConnectConfig?, val exception: Exception? = null, val isWifiConnected: Boolean = false, ) + +/** + * Status of the ConnectWifiLauncher during a Wi-Fi connection attempt. + */ enum class ConnectWifiLauncherStatus { INACTIVE, REQUESTING_PERMISSION, LOOKING_FOR_NETWORK, REQUESTING_LINK, } +/** + * Composable function that provides a [ConnectWifiLauncher] for managing Wi-Fi connections. + * + * It handles permission requests, network association via [CompanionDeviceManager], + * and provides status updates and connection results. + * + * @param node The [AndroidVirtualNode] representing the local virtual node. + * @param logger Optional logger for debugging messages. + * @param onStatusChange Optional callback invoked when the launcher status changes. + * @param onResult Callback invoked with the result of the Wi-Fi connection attempt. + * + * @return A [ConnectWifiLauncher] that can be used to initiate Wi-Fi connections. + */ @Composable fun meshrabiyaConnectLauncher( node: AndroidVirtualNode, @@ -236,4 +273,4 @@ fun meshrabiyaConnectLauncher( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/db/MeshDatabase.kt b/app/src/main/java/com/greybox/projectmesh/db/MeshDatabase.kt index 1f3dba4cc..05dfebed9 100644 --- a/app/src/main/java/com/greybox/projectmesh/db/MeshDatabase.kt +++ b/app/src/main/java/com/greybox/projectmesh/db/MeshDatabase.kt @@ -9,6 +9,12 @@ import com.greybox.projectmesh.messaging.data.entities.Conversation import com.greybox.projectmesh.user.UserDao import com.greybox.projectmesh.user.UserEntity +/** + * Room database for the ProjectMesh application. + * + * This database stores messages, conversations, and user entities. + * It provides DAOs to access and manipulate each type of data. + */ @Database( entities = [ Message::class, @@ -19,7 +25,25 @@ import com.greybox.projectmesh.user.UserEntity exportSchema = false ) abstract class MeshDatabase : RoomDatabase() { + + /** + * Provides access to message-related database operations. + * + * @return A [MessageDao] instance for querying and modifying messages. + */ abstract fun messageDao(): MessageDao + + /** + * Provides access to user-related database operations. + * + * @return A [UserDao] instance for querying and modifying user entities. + */ abstract fun userDao(): UserDao + + /** + * Provides access to conversation-related database operations. + * + * @return A [ConversationDao] instance for querying and modifying conversations. + */ abstract fun conversationDao(): ConversationDao } 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 3cc15d6a9..5c7cf28d7 100644 --- a/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt +++ b/app/src/main/java/com/greybox/projectmesh/debug/CrashHandler.kt @@ -12,24 +12,48 @@ import java.lang.Exception import java.lang.Thread.UncaughtExceptionHandler import kotlin.system.exitProcess +/** + * Custom [Thread.UncaughtExceptionHandler] to handle uncaught exceptions in the app. + * + * This handler launches a specified activity when a crash occurs, passing the exception + * details via an Intent, and then terminates the app. + * + * @param context The application context used to launch the crash activity. + * @param defaultHandler The default uncaught exception handler to fallback on. + * @param activityToBeLaunched The activity class to be launched when a crash occurs. + */ +class CrashHandler( + private val context: Context, + private val defaultHandler: UncaughtExceptionHandler, + private val activityToBeLaunched: Class<*> +) : Thread.UncaughtExceptionHandler { -class CrashHandler(private val context: Context, private val defaultHandler: UncaughtExceptionHandler, private val activityToBeLaunched: Class<*>) : Thread.UncaughtExceptionHandler { - + /** + * Handles uncaught exceptions thrown by any thread. + * + * @param thread The thread where the exception occurred. + * @param throwable The uncaught exception. + */ override fun uncaughtException(thread: Thread, throwable: Throwable) { try { - launchActivity(context,activityToBeLaunched,throwable) + launchActivity(context, activityToBeLaunched, throwable) exitProcess(status = 1) - } catch (e: Exception) - { - defaultHandler.uncaughtException(thread,throwable) + } catch (e: Exception) { + defaultHandler.uncaughtException(thread, throwable) } } - private fun launchActivity(applicationContext: Context, activity: Class<*>, exception: Throwable) - { + /** + * Launches the crash reporting activity with the exception details. + * + * @param applicationContext The context used to start the activity. + * @param activity The activity class to launch. + * @param exception The exception to pass to the activity. + */ + private fun launchActivity(applicationContext: Context, activity: Class<*>, exception: Throwable) { val crashIntent = Intent(applicationContext, activity).also { it.putExtra("CrashData", Gson().toJson(exception)) - Log.e("Project Mesh Error","Error: ",exception); + Log.e("Project Mesh Error", "Error: ", exception) } crashIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) @@ -38,22 +62,34 @@ class CrashHandler(private val context: Context, private val defaultHandler: Unc } companion object { - fun init(applicationContext: Context, activityToBeLaunched: Class<*>) - { - val handler = CrashHandler(applicationContext,Thread.getDefaultUncaughtExceptionHandler() as UncaughtExceptionHandler, activityToBeLaunched) + /** + * Initializes the [CrashHandler] and sets it as the default uncaught exception handler. + * + * @param applicationContext The application context used to create the handler. + * @param activityToBeLaunched The activity class to launch on crash. + */ + fun init(applicationContext: Context, activityToBeLaunched: Class<*>) { + val handler = CrashHandler( + applicationContext, + Thread.getDefaultUncaughtExceptionHandler() as UncaughtExceptionHandler, + activityToBeLaunched + ) Thread.setDefaultUncaughtExceptionHandler(handler) } - fun getThrowableFromIntent(intent: Intent): Throwable? - { + /** + * Retrieves a [Throwable] from an intent containing crash data. + * + * @param intent The intent containing serialized crash data. + * @return The deserialized [Throwable], or null if parsing fails. + */ + fun getThrowableFromIntent(intent: Intent): Throwable? { return try { Gson().fromJson(intent.getStringExtra("CrashData"), Throwable::class.java) - } - catch (e: Exception) { - Log.e("CrashHandler","getThrowableFromIntent: ",e); + } catch (e: Exception) { + Log.e("CrashHandler", "getThrowableFromIntent: ", e) null } - } } } diff --git a/app/src/main/java/com/greybox/projectmesh/extension/ContentResolverExtension.kt b/app/src/main/java/com/greybox/projectmesh/extension/ContentResolverExtension.kt index bf474affe..2f3c4a0da 100644 --- a/app/src/main/java/com/greybox/projectmesh/extension/ContentResolverExtension.kt +++ b/app/src/main/java/com/greybox/projectmesh/extension/ContentResolverExtension.kt @@ -5,6 +5,12 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.core.net.toFile +/** + * Represents the name and size of a file referenced by a [Uri]. + * + * @property name The display name of the file, or null if it cannot be determined. + * @property size The size of the file in bytes, or -1 if unknown. + */ data class UriNameAndSize( val name: String?, val size: Long, @@ -16,12 +22,23 @@ It will return a UriNameAndSize object that contains the name and size of the fi Two Condition: 1. The uri is a file uri 2. The uri is a content uri +*/ + +/** + * Retrieves the name and size of a file referenced by the given [uri]. + * + * Supports both "file" scheme URIs and "content" scheme URIs. + * + * @receiver The [ContentResolver] used to query content URIs. + * @param uri The [Uri] pointing to the file. + * @return A [UriNameAndSize] object containing the file's name and size, or + * null name and -1 size if the information cannot be determined. */ fun ContentResolver.getUriNameAndSize(uri: Uri): UriNameAndSize { return if(uri.scheme == "file") { val uriFile = uri.toFile() UriNameAndSize(uriFile.name, uriFile.length()) - }else { + } else { query( uri, null, null, null, null )?.use { cursor -> @@ -35,7 +52,7 @@ fun ContentResolver.getUriNameAndSize(uri: Uri): UriNameAndSize { cursor.getString(sizeIndex) } UriNameAndSize(cursor.getString(nameIndex), size?.toLong() ?: -1L) - }else { + } else { null } } ?: UriNameAndSize(null, -1) diff --git a/app/src/main/java/com/greybox/projectmesh/extension/ContextExt.kt b/app/src/main/java/com/greybox/projectmesh/extension/ContextExt.kt index c2cfa4632..beb3159c6 100644 --- a/app/src/main/java/com/greybox/projectmesh/extension/ContextExt.kt +++ b/app/src/main/java/com/greybox/projectmesh/extension/ContextExt.kt @@ -14,11 +14,13 @@ import com.ustadmobile.meshrabiya.MeshrabiyaConstants /* context is a class that provides access to application-specific resources and classes. This File contains several context related extension functions that will use in this app. - */ +*/ /** - * On Android 13+ we can use the NEARBY_WIFI_DEVICES permission instead of the location permission. - * On earlier versions, we need fine location permission + * The permission string required for accessing nearby Wi-Fi devices. + * + * On Android 13+ (SDK 33+), uses [Manifest.permission.NEARBY_WIFI_DEVICES]. + * On earlier versions, falls back to [Manifest.permission.ACCESS_FINE_LOCATION]. */ val NEARBY_WIFI_PERMISSION_NAME = if(Build.VERSION.SDK_INT >= 33){ Manifest.permission.NEARBY_WIFI_DEVICES @@ -26,32 +28,65 @@ val NEARBY_WIFI_PERMISSION_NAME = if(Build.VERSION.SDK_INT >= 33){ Manifest.permission.ACCESS_FINE_LOCATION } -// check if the app has the nearby wifi devices permission +/** + * Checks whether the app has permission to access nearby Wi-Fi devices (Android 13+) + * or fine location (pre-Android 13). + * + * @receiver The [Context] used to check permissions. + * @return `true` if the required permission is granted, `false` otherwise. + */ fun Context.hasNearbyWifiDevicesOrLocationPermission(): Boolean { return ContextCompat.checkSelfPermission( this, NEARBY_WIFI_PERMISSION_NAME ) == PackageManager.PERMISSION_GRANTED } -// check if the app has the bluetooth connect permission +/** + * Checks whether the app has permission to connect to Bluetooth devices. + * + * On Android 12+ (SDK 31+), uses [Manifest.permission.BLUETOOTH_CONNECT]. + * On earlier versions, always returns `true`. + * + * @receiver The [Context] used to check permissions. + * @return `true` if the permission is granted or not required, `false` otherwise. + */ fun Context.hasBluetoothConnectPermission(): Boolean { return if(Build.VERSION.SDK_INT >= 31) { ContextCompat.checkSelfPermission( this, Manifest.permission.BLUETOOTH_CONNECT ) == PackageManager.PERMISSION_GRANTED - }else { + } else { true } } -// create a DataStore instance that Meshrabiya can use to remember networks +/** + * Provides a [DataStore] instance named "meshr_settings" for storing persistent + * network-related preferences used by Meshrabiya. + */ val Context.networkDataStore: DataStore by preferencesDataStore(name = "meshr_settings") -// Check if the device supports WiFi STA/AP Concurrency +/** + * Checks if the device supports Wi-Fi STA/AP concurrency (simultaneous station and access point mode). + * + * Requires Android 11+ (SDK 30+). + * + * @receiver The [Context] used to access [WifiManager]. + * @return `true` if STA/AP concurrency is supported, `false` otherwise. + */ fun Context.hasStaApConcurrency(): Boolean { return Build.VERSION.SDK_INT >= 30 && getSystemService(WifiManager::class.java).isStaApConcurrencySupported } +/** + * Returns a detailed string describing the device and Wi-Fi capabilities. + * + * Includes Meshrabiya version, Android version, device manufacturer/model, + * 5GHz support, local-only station concurrency, STA/AP concurrency, and Wi-Fi Aware support. + * + * @receiver The [Context] used to access system services and package manager. + * @return A formatted [String] describing the device and Wi-Fi features. + */ fun Context.deviceInfo(): String { val wifiManager = getSystemService(WifiManager::class.java) val hasStaConcurrency = Build.VERSION.SDK_INT >= 31 && @@ -69,4 +104,4 @@ fun Context.deviceInfo(): String { append("Station-AP concurrency: $hasStaApConcurrency\n") append("WifiAware support: $hasWifiAwareSupport\n") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/extension/ListExtension.kt b/app/src/main/java/com/greybox/projectmesh/extension/ListExtension.kt index 598d619f5..52282b949 100644 --- a/app/src/main/java/com/greybox/projectmesh/extension/ListExtension.kt +++ b/app/src/main/java/com/greybox/projectmesh/extension/ListExtension.kt @@ -3,6 +3,16 @@ package com.greybox.projectmesh.extension /* This is an extension function on Kotlin's List class, allowing to apply an update to the first element in a list that matches a given condition, then returning a updated list +*/ + +/** + * Returns a new list with the first element that satisfies [condition] updated by [function]. + * + * If no element matches [condition], the original list is returned unchanged. + * + * @param condition A predicate to identify which element to update. + * @param function A transformation function applied to the matching element. + * @return A new [List] with the updated element, or the original list if no element matches. */ inline fun List.updateItem( condition: (T) -> Boolean, @@ -20,4 +30,4 @@ inline fun List.updateItem( newList -> newList[index] = function(this[index]) }.toList() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/extension/NetworkUtils.kt b/app/src/main/java/com/greybox/projectmesh/extension/NetworkUtils.kt index d9b8ae0ef..1765ea6bd 100644 --- a/app/src/main/java/com/greybox/projectmesh/extension/NetworkUtils.kt +++ b/app/src/main/java/com/greybox/projectmesh/extension/NetworkUtils.kt @@ -4,8 +4,14 @@ import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode import org.kodein.di.DI import org.kodein.di.instance +/** + * Retrieves the local IP address of the [AndroidVirtualNode] from a Kodein [DI] container. + * + * @param di The [DI] instance used to obtain the [AndroidVirtualNode]. + * @return The host IP address of the node as a [String]. + */ fun getLocalIpFromDI(di: DI): String { // Retrieve the AndroidVirtualNode from DI and return its IP address val node: AndroidVirtualNode by di.instance() return node.address.hostAddress -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/extension/WifiListItem.kt b/app/src/main/java/com/greybox/projectmesh/extension/WifiListItem.kt index d8992dfc3..ab94ae781 100644 --- a/app/src/main/java/com/greybox/projectmesh/extension/WifiListItem.kt +++ b/app/src/main/java/com/greybox/projectmesh/extension/WifiListItem.kt @@ -24,8 +24,18 @@ import com.ustadmobile.meshrabiya.ext.addressToDotNotation import com.ustadmobile.meshrabiya.vnet.VirtualNode import kotlinx.coroutines.runBlocking import com.greybox.projectmesh.user.UserRepository + +/** + * Displays a single Wi-Fi node in a list with device information and mesh network status. + * + * This composable shows the device icon, name (from IP address), IP in dot notation, + * and mesh network details including ping time and hop count. + * + * @param wifiAddress The integer IP address of the Wi-Fi node. + * @param wifiEntry The [VirtualNode.LastOriginatorMessage] containing the node's mesh message data. + * @param onClick Optional lambda invoked when the list item is clicked, providing the node's IP in dot notation. + */ @Composable -// Display a single connected wifi station fun WifiListItem( wifiAddress: Int, wifiEntry: VirtualNode.LastOriginatorMessage, @@ -89,4 +99,4 @@ fun WifiListItem( } ) HorizontalDivider() -} \ No newline at end of file +} 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/dao/ConversationDao.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/ConversationDao.kt index 8e1439369..5616eedd6 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/ConversationDao.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/ConversationDao.kt @@ -8,32 +8,91 @@ import androidx.room.Update import com.greybox.projectmesh.messaging.data.entities.Conversation import kotlinx.coroutines.flow.Flow +/** + * Data Access Object for [Conversation] entities. + * + * Provides methods to query, insert, and update conversations in the Room database. + */ @Dao interface ConversationDao { + + /** + * Returns a flow of all conversations, sorted by the timestamp of the last message in descending order. + * + * @return [Flow] emitting a list of [Conversation] objects whenever the data changes. + */ @Query("SELECT * FROM conversations ORDER BY last_message_time DESC") fun getAllConversationsFlow(): Flow> + /** + * Retrieves a conversation by its unique ID. + * + * @param conversationId The unique ID of the conversation. + * @return The [Conversation] if found, or `null` if no matching conversation exists. + */ @Query("SELECT * FROM conversations WHERE id = :conversationId LIMIT 1") suspend fun getConversationById(conversationId: String): Conversation? + /** + * Retrieves a conversation associated with a specific user UUID. + * + * @param userUuid The UUID of the user. + * @return The [Conversation] if found, or `null` if no matching conversation exists. + */ @Query("SELECT * FROM conversations WHERE user_uuid = :userUuid LIMIT 1") suspend fun getConversationByUserUuid(userUuid: String): Conversation? + /** + * Inserts a new conversation into the database. + * + * If a conversation with the same ID already exists, it will be replaced. + * + * @param conversation The [Conversation] to insert. + */ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertConversation(conversation: Conversation) + /** + * Updates an existing conversation in the database. + * + * @param conversation The [Conversation] to update. + */ @Update suspend fun updateConversation(conversation: Conversation) + /** + * Updates the online status and user address for a conversation based on the user UUID. + * + * @param userUuid The UUID of the user. + * @param isOnline Whether the user is currently online. + * @param userAddress The user's network address (nullable). + */ @Query("UPDATE conversations SET is_online = :isOnline, user_address = :userAddress WHERE user_uuid = :userUuid") suspend fun updateUserConnectionStatus(userUuid: String, isOnline: Boolean, userAddress: String?) + /** + * Updates the last message and its timestamp for a specific conversation. + * + * @param conversationId The unique ID of the conversation. + * @param lastMessage The latest message text. + * @param timestamp The time when the last message was sent. + */ @Query("UPDATE conversations SET last_message = :lastMessage, last_message_time = :timestamp WHERE id = :conversationId") suspend fun updateLastMessage(conversationId: String, lastMessage: String, timestamp: Long) + /** + * Increments the unread message count for a specific conversation by 1. + * + * @param conversationId The unique ID of the conversation. + */ @Query("UPDATE conversations SET unread_count = unread_count + 1 WHERE id = :conversationId") suspend fun incrementUnreadCount(conversationId: String) + /** + * Clears the unread message count for a specific conversation, setting it to 0. + * + * @param conversationId The unique ID of the conversation. + */ @Query("UPDATE conversations SET unread_count = 0 WHERE id = :conversationId") suspend fun clearUnreadCount(conversationId: String) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/MessageDao.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/MessageDao.kt index 7afe14e0a..6dbe684e4 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/MessageDao.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/dao/MessageDao.kt @@ -7,34 +7,84 @@ import androidx.room.Query import com.greybox.projectmesh.messaging.data.entities.Message import kotlinx.coroutines.flow.Flow +/** + * Data Access Object for [Message] entities. + * + * Provides methods to query, insert, and delete messages in the Room database. + */ @Dao interface MessageDao { + + /** + * Retrieves all messages as a synchronous list. + * + * @return A [List] of all [Message] objects in the database. + */ @Query("SELECT * FROM message") fun getAll(): List + /** + * Returns a flow of all messages. + * + * @return [Flow] emitting a list of [Message] objects whenever the data changes. + */ @Query("SELECT * FROM message") fun getAllFlow(): Flow> + /** + * Returns a flow of messages for a specific chat, ordered by date received ascending. + * + * @param chat The chat identifier. + * @return [Flow] emitting a list of [Message] objects for the given chat. + */ @Query("SELECT * FROM message WHERE chat = :chat ORDER BY dateReceived ASC") fun getChatMessagesFlow(chat: String): Flow> + /** + * Deletes all messages from the database. + */ @Query("DELETE FROM message") fun clearTable() + /** + * Returns a flow of messages for multiple chat names, ordered by date received ascending. + * + * @param chatNames A list of chat identifiers. + * @return [Flow] emitting a list of [Message] objects for the given chats. + */ @Query("SELECT * FROM message WHERE chat IN (:chatNames) ORDER BY dateReceived ASC") fun getChatMessagesFlowMultipleNames(chatNames: List): Flow> - //Synchronously Query to get messages immediately + /** + * Synchronously retrieves messages for a specific chat, ordered by date received ascending. + * + * @param chat The chat identifier. + * @return A [List] of [Message] objects for the given chat. + */ @Query("SELECT * FROM message WHERE chat = :chat ORDER BY dateReceived ASC") fun getChatMessagesSync(chat: String): List + /** + * Inserts a new message into the database. + * + * @param m The [Message] to add. + */ @Insert suspend fun addMessage(m: Message) + /** + * Deletes a single message from the database. + * + * @param m The [Message] to delete. + */ @Delete fun delete(m: Message) + /** + * Deletes multiple messages from the database. + * + * @param messages The list of [Message] objects to delete. + */ @Delete suspend fun deleteAll(messages: List) - } diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Conversation.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Conversation.kt index aca71676b..4d649ca63 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Conversation.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Conversation.kt @@ -5,8 +5,20 @@ import androidx.room.Entity import androidx.room.PrimaryKey import java.util.UUID -//Conversation Entity, representing a chat thread with another user - +/** + * Represents a conversation (chat thread) with another user. + * + * Each conversation tracks the other user's info, the last message, unread count, and online status. + * + * @property id The unique ID of the conversation, typically a composite of the two users' IDs. + * @property userUuid The UUID of the other user in the conversation. + * @property userName The display name of the other user. + * @property userAddress The IP address of the other user (nullable). + * @property lastMessage The text of the last message in the conversation (nullable). + * @property lastMessageTime Timestamp of when the last message was sent. + * @property unreadCount The number of unread messages in this conversation (default 0). + * @property isOnline Indicates whether the other user is currently online (default false). + */ @Entity(tableName = "conversations") data class Conversation( @PrimaryKey val id: String, //Composite id of the two users @@ -17,5 +29,4 @@ data class Conversation( @ColumnInfo(name = "last_message_time") val lastMessageTime: Long, //Timestamp of last message @ColumnInfo(name = "unread_count") val unreadCount: Int = 0, //count of unread messages @ColumnInfo(name = "is_online") val isOnline: Boolean = false //whether the user is online - -) \ No newline at end of file +) 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 25bcfbf44..716d1d8d2 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 @@ -53,68 +53,102 @@ import java.net.URL import java.net.URLConnection import java.net.URLDecoder -//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. +/** + * Utility class for encoding and decoding files using Base64. + * + * This class provides functions to encode files to Base64 strings, decode + * Base64 strings back to files, and send files over HTTP as encoded strings. + */ +class FileEncoder { //Made by Craig. Encodes via base64. + /** + * Encodes the file located at the given URI into a Base64 string. + * + * @param ctxt The application context used to access the content resolver. + * @param inputuri The URI of the file to encode. + * @return The Base64-encoded string of the file contents, or a message + * indicating encoding failure. + */ @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)//Made by Craig - fun decodeBase64(inputbase64:String, output: File): File{//Decodes to a file. Uses base64 + /** + * Decodes a Base64-encoded string and writes it to the specified file. + * + * @param inputbase64 The Base64 string to decode. + * @param output The file to write the decoded bytes to. + * @return The file containing the decoded data. + */ + @OptIn(ExperimentalEncodingApi::class) //Made by Craig + fun decodeBase64(inputbase64:String, output: File): File { //Decodes to a file. Uses base64 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 + + @OptIn(ExperimentalEncodingApi::class) + internal fun encodeBytesBase64(bytes: ByteArray?): String? { + return if (bytes != null) { + Base64.encode(bytes) + } else { + "Cannot encode file" + } + } + + + + /** + * Sends an image file to a target host and port using HTTP POST with Base64 encoding. + * + * @param imageURI The URI of the image to send. If null, the function returns false. + * @param tgtaddress The target host's InetAddress. + * @param tgtport The target port to send the image to. + * @param appctxt The application context used to access content resolver streams. + * @return True if the image was successfully sent, false otherwise. + */ + 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) + 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() + } else { + return false } - outstream.close() - instream?.close() - } 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/data/entities/JSONSchema.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/JSONSchema.kt index 69ce291f0..a14022939 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/JSONSchema.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/JSONSchema.kt @@ -4,6 +4,11 @@ import android.util.Log import org.json.JSONObject import org.json.JSONException +/** + * Utility class to validate JSON strings against a predefined JSON schema. + * + * The schema enforces required fields and data types for messages. + */ class JSONSchema { private val schemaString = """ @@ -20,7 +25,13 @@ class JSONSchema { } } """ - //Takes JSON string and validates it against JSON Schema + + /** + * Validates a JSON string against the internal schema. + * + * @param json The JSON string representing a message. + * @return True if the JSON is valid according to the schema, false otherwise. + */ fun schemaValidation(json: String): Boolean { //Log.d("JSONSchema", "Validating JSON: $json") //Log.d("JSONSchema", "Against schema: $schemaString") @@ -30,13 +41,19 @@ class JSONSchema { validate(jsonObject, schemaJson) return true - }catch (e: JSONException) { + } catch (e: JSONException) { Log.e("JSONSchema", "JSON schema validation failed: ${e.message}") return false } } - //Validates JSON object against schema + /** + * Checks that the given JSON object contains all required fields as per the schema. + * + * @param json The JSON object to validate. + * @param schema The JSON schema object defining required fields. + * @throws JSONException If any required field is missing. + */ private fun validate(json: JSONObject, schema: JSONObject) { val requiredFields = schema.getJSONArray("required") for (i in 0 until requiredFields.length()) { @@ -46,4 +63,4 @@ class JSONSchema { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Message.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Message.kt index f48afdd8f..f01f5f705 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Message.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/Message.kt @@ -13,34 +13,80 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.net.URI -class URIConverter{ + +/** + * Room type converter to convert between [URI] and [String] for database storage. + */ +class URIConverter { + /** + * Converts a [URI] to a [String] for database storage. + * + * @param theuri The URI to convert. + * @return The string representation of the URI, or null if input is null. + */ @TypeConverter - fun convfromURI(theuri: URI?): String?{ + fun convfromURI(theuri: URI?): String? { return theuri?.toString() } + + /** + * Converts a [String] back to a [URI]. + * + * @param uristring The string to convert. + * @return The corresponding URI, or null if input is null. + */ @TypeConverter - fun convtoURI(uristring: String?): URI?{ - return uristring?.let{URI.create(it)} + fun convtoURI(uristring: String?): URI? { + return uristring?.let { URI.create(it) } } } -object URISerializable : KSerializer {//This makes the URI serializable, can be used in JSON + +/** + * Serializer to make [URI] serializable for Kotlinx serialization (e.g., JSON). + */ +object URISerializable : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("URI", PrimitiveKind.STRING) + + /** + * Serializes a [URI] into a string. + * + * @param enc The encoder. + * @param vals The URI to serialize. + */ override fun serialize(enc: Encoder, vals: URI) { enc.encodeString(vals.toString()) } + + /** + * Deserializes a string into a [URI]. + * + * @param dec The decoder. + * @return The deserialized URI. + */ override fun deserialize(dec: Decoder): URI { return URI.create(dec.decodeString()) } } + +/** + * Room entity representing a message in a chat. + * + * @property id Unique message ID (auto-generated). + * @property dateReceived Timestamp when the message was received. + * @property content The text content of the message. + * @property sender The identifier of the sender. + * @property chat The chat/conversation ID this message belongs to. + * @property file Optional file attached to the message, stored as a [URI]. + */ @Serializable @Entity(tableName = "message") @TypeConverters(URIConverter::class) -data class Message(// +data class Message( @PrimaryKey(autoGenerate = true) val id: Int, @ColumnInfo(name = "dateReceived") val dateReceived: Long, @ColumnInfo(name = "content") val content: String, @ColumnInfo(name = "sender") val sender: String, @ColumnInfo(name = "chat") val chat: String, - @ColumnInfo(name= "file") @Serializable(with=URISerializable::class) val file: URI? = null - //@ColumnInfo(name = "file") @Serializable(with=URISerializable::class) val file: List -) \ No newline at end of file + @ColumnInfo(name = "file") @Serializable(with = URISerializable::class) val file: URI? = null + // @ColumnInfo(name = "file") @Serializable(with=URISerializable::class) val file: List +) diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandler.kt b/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandler.kt index 1a15b2f4f..01239b0c8 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandler.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageNetworkHandler.kt @@ -36,6 +36,13 @@ import android.os.Parcel import android.os.Parcelable import java.net.URI +/** + * Handles sending and receiving chat messages over the network. + * + * @property httpClient The OkHttpClient used to make HTTP requests. + * @property localVirtualAddr The local device's virtual network IP address. + * @property di The Kodein DI container instance for retrieving dependencies. + */ class MessageNetworkHandler( private val httpClient: OkHttpClient, private val localVirtualAddr: InetAddress, @@ -45,7 +52,14 @@ class MessageNetworkHandler( private val conversationRepository: ConversationRepository by di.instance() private val settingsPrefs: SharedPreferences by di.instance(tag = "settings") - //function sendChatMessage(address: InetAddress, time: Long, message: String) { + /** + * Sends a chat message to a remote device over HTTP. + * + * @param address The target device's IP address. + * @param time The timestamp of the message in milliseconds. + * @param message The message text to send. + * @param file Optional URI of a file to send along with the message. + */ fun sendChatMessage(address: InetAddress, time: Long, message: String, file: URI?/* test this*/) { scope.launch { try { @@ -103,8 +117,17 @@ class MessageNetworkHandler( } } } + companion object { - //process incoming messages and route them to the correct conversation + /** + * Processes an incoming message and routes it to the correct conversation. + * + * @param chatMessage The message content received. + * @param time The timestamp when the message was received. + * @param senderIp The IP address of the sender. + * @param incomingfile Optional file URI attached to the message. + * @return The created [Message] object representing the incoming message. + */ fun handleIncomingMessage( chatMessage: String?, time: Long, @@ -176,8 +199,13 @@ class MessageNetworkHandler( return message } - - // New helper function to show notifications that route to chat screen + /** + * Shows a notification for an incoming message and routes to the chat screen. + * + * @param conversation The conversation to which the message belongs. + * @param message The message to display in the notification. + * @param senderIp The IP address of the sender. + */ private fun showMessageNotification( conversation: Conversation, message: Message, @@ -235,4 +263,4 @@ class MessageNetworkHandler( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageService.kt b/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageService.kt index 87317a376..ade62a252 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageService.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/network/MessageService.kt @@ -15,6 +15,12 @@ import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.instance +/** + * Service layer for handling message operations including sending messages + * and updating conversations. + * + * @property di Kodein DI container for retrieving required dependencies. + */ class MessageService( override val di: DI ) : DIAware { @@ -24,6 +30,13 @@ class MessageService( private val userRepository: UserRepository by di.instance() private val settingsPrefs: SharedPreferences by di.instance(tag = "settings") + /** + * Sends a message to a given IP address. + * First saves the message locally, then sends it over the network. + * + * @param address The target device's IP address. + * @param message The [Message] object to be sent. + */ suspend fun sendMessage(address: InetAddress, message: Message) { //First save locally messageRepository.addMessage(message) @@ -37,6 +50,12 @@ class MessageService( ) } + /** + * Updates the conversation associated with a given user IP with a new message. + * + * @param address The IP address of the remote user. + * @param message The [Message] object to update in the conversation. + */ private suspend fun updateConversationWithMessage(address: InetAddress, message: Message){ try { //find user by ip address @@ -64,4 +83,4 @@ class MessageService( Log.e("MessageService", "Error updating conversation with message", e) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/repository/ConversationRepository.kt b/app/src/main/java/com/greybox/projectmesh/messaging/repository/ConversationRepository.kt index 81e8e79c5..450d264a2 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/repository/ConversationRepository.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/repository/ConversationRepository.kt @@ -10,17 +10,33 @@ import kotlinx.coroutines.flow.Flow import org.kodein.di.DI import org.kodein.di.DIAware +/** + * Repository for managing conversations. + * Handles retrieval, creation, updating, and user status tracking for conversations. + * + * @property conversationDao DAO for database operations related to conversations. + * @property di Kodein DI container for dependency injection. + */ class ConversationRepository( private val conversationDao: ConversationDao, override val di: DI ) : DIAware { - //get all conversations as a flow + /** + * Returns a [Flow] emitting the list of all conversations. + * + * @return A [Flow] of [List] of [Conversation]. + */ fun getAllConversations(): Flow>{ return conversationDao.getAllConversationsFlow() } - //get specific convo by id + /** + * Retrieves a conversation by its unique ID. + * + * @param conversationId The unique ID of the conversation. + * @return The [Conversation] if found, null otherwise. + */ suspend fun getConversationById(conversationId: String): Conversation? { Log.d("ConversationRepository", "Getting conversation by ID: $conversationId") val result = conversationDao.getConversationById(conversationId) @@ -28,6 +44,13 @@ class ConversationRepository( return result } + /** + * Retrieves an existing conversation or creates a new one if it does not exist. + * + * @param localUuid The UUID of the local user. + * @param remoteUser The remote user entity to associate with the conversation. + * @return The existing or newly created [Conversation]. + */ suspend fun getOrCreateConversation(localUuid: String, remoteUser: UserEntity): Conversation { //create a unique conversation ID using both UUIDs in order to ensure consistency val conversationId = ConversationUtils.createConversationId(localUuid, remoteUser.uuid) @@ -60,7 +83,13 @@ class ConversationRepository( return conversation } - //update conversation with the latest message + /** + * Updates the conversation with the latest message. + * Increments unread count if the message is from another user. + * + * @param conversationId The unique ID of the conversation. + * @param message The [Message] to update in the conversation. + */ suspend fun updateWithMessage(conversationId: String, message: Message) { conversationDao.updateLastMessage( @@ -82,12 +111,22 @@ class ConversationRepository( } - //mark conversation as read + /** + * Marks a conversation as read by clearing its unread count. + * + * @param conversationId The unique ID of the conversation. + */ suspend fun markAsRead(conversationId: String) { conversationDao.clearUnreadCount(conversationId) } - //update a user's online status + /** + * Updates a user's online status and associated address. + * + * @param userUuid The UUID of the user. + * @param isOnline True if the user is online, false otherwise. + * @param userAddress The current address of the user, if available. + */ suspend fun updateUserStatus(userUuid: String, isOnline: Boolean, userAddress: String?) { try { // Update in database diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/repository/MessageRepository.kt b/app/src/main/java/com/greybox/projectmesh/messaging/repository/MessageRepository.kt index adf8b069f..4ca0b759b 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/repository/MessageRepository.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/repository/MessageRepository.kt @@ -1,4 +1,3 @@ -// Path: app/src/main/java/com/greybox/projectmesh/messaging/repository/MessageRepository.kt package com.greybox.projectmesh.messaging.repository import com.greybox.projectmesh.messaging.data.dao.MessageDao @@ -8,27 +7,51 @@ import org.kodein.di.DI import org.kodein.di.DIAware // Changed to use Kodein instead of javax.inject + +/** + * Repository for managing messages in the app. + * Handles retrieval, insertion, and clearing of messages for chats. + * + * @property messageDao DAO for database operations related to messages. + * @property di Kodein DI container for dependency injection. + */ class MessageRepository( private val messageDao: MessageDao, override val di: DI ) : DIAware { - // Get all messages for a chat + + /** + * Retrieves all messages for a specific chat as a [Flow]. + * + * @param chatId The ID of the chat to retrieve messages for. + * @return A [Flow] emitting a [List] of [Message] objects for the chat. + */ fun getChatMessages(chatId: String): Flow> { return messageDao.getChatMessagesFlow(chatId) } - // Add a new message + /** + * Adds a new message to the database. + * + * @param message The [Message] to add. + */ suspend fun addMessage(message: Message) { messageDao.addMessage(message) } - // Get all messages + /** + * Retrieves all messages from the database as a [Flow]. + * + * @return A [Flow] emitting a [List] of all [Message] objects. + */ fun getAllMessages(): Flow> { return messageDao.getAllFlow() } - // Clear all messages + /** + * Clears all messages from the database. + */ suspend fun clearMessages() { messageDao.clearTable() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModel.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModel.kt index a0e5b60bf..67a7c358e 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModel.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ChatScreenModel.kt @@ -3,9 +3,17 @@ package com.greybox.projectmesh.messaging.ui.models import com.greybox.projectmesh.messaging.data.entities.Message import java.net.InetAddress +/** + * Data model representing the state of a chat screen in the UI. + * + * @property deviceName Optional name of the device or user. + * @property virtualAddress Virtual network address of the device; defaults to 192.168.0.1. + * @property allChatMessages List of all messages to display on the chat screen; defaults to empty list. + * @property offlineWarning Optional warning message to show if the device/user is offline. + */ data class ChatScreenModel( val deviceName: String? = null, val virtualAddress: InetAddress = InetAddress.getByName("192.168.0.1"), val allChatMessages: List = emptyList(), val offlineWarning: String? = null -) \ No newline at end of file +) diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModel.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModel.kt index 2c3fd30d5..227b2b69e 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModel.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/models/ConversationsHomeScreenModel.kt @@ -2,8 +2,15 @@ package com.greybox.projectmesh.messaging.ui.models import com.greybox.projectmesh.messaging.data.entities.Conversation +/** + * Data model representing the state of the home screen showing all conversations. + * + * @property isLoading Indicates whether conversation data is currently being loaded. + * @property conversations List of conversations to display on the home screen; defaults to empty list. + * @property error Optional error message to display if loading or retrieving conversations fails. + */ data class ConversationsHomeScreenModel ( val isLoading: Boolean = false, val conversations: List = emptyList(), val error: String? = null -) \ No newline at end of file +) diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreen.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreen.kt index 90c1b3d03..78fdfea1b 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreen.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatNodeListScreen.kt @@ -13,6 +13,13 @@ import org.kodein.di.compose.localDI import androidx.compose.ui.platform.LocalSavedStateRegistryOwner import com.greybox.projectmesh.viewModel.NetworkScreenModel +/** + * Composable that displays a list of network nodes as clickable items. + * + * @param onNodeSelected Lambda invoked when a node is selected; passes the node's IP address as a [String]. + * @param viewModel Optional [NetworkScreenViewModel] instance to provide network node data. + * Defaults to a ViewModel created with [ViewModelFactory] using the local DI context. + */ @Composable fun ChatNodeListScreen( onNodeSelected: (String) -> Unit, @@ -46,4 +53,4 @@ fun ChatNodeListScreen( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreen.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreen.kt index 24f690dd4..7b0ad6d64 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreen.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreen.kt @@ -74,6 +74,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +/** + * Composable function representing the main chat screen. + * + * @param virtualAddress The IP address of the chat participant. + * @param userName Optional username for the chat participant. + * @param isOffline Boolean flag indicating if the user is offline. + * @param onClickButton Callback for button click events. + * @param viewModel The [ChatScreenViewModel] providing UI state and actions. + */ @Composable fun ChatScreen( virtualAddress: InetAddress, @@ -89,7 +98,6 @@ fun ChatScreen( }, defaultArgs = Bundle().apply { putSerializable("virtualAddress", virtualAddress) - } ) ) @@ -276,6 +284,13 @@ fun ChatScreen( } } +/** + * Composable function showing the user's status bar at the top of the chat. + * + * @param userName Name of the chat participant. + * @param isOnline Boolean flag indicating online/offline status. + * @param userAddress IP address of the chat participant. + */ @Composable fun UserStatusBar( userName: String, @@ -366,6 +381,12 @@ fun UserStatusBar( } } +/** + * Composable function displaying all messages in the chat. + * + * @param uiState [ChatScreenModel] representing the current state of the chat. + * @param onClickButton Callback for any button interactions within the messages list. + */ @Composable fun DisplayAllMessages(uiState: ChatScreenModel, onClickButton: () -> Unit) { val context = LocalContext.current @@ -441,6 +462,15 @@ fun DisplayAllMessages(uiState: ChatScreenModel, onClickButton: () -> Unit) { } } +/** + * Composable function displaying an individual message bubble. + * + * @param chatMessage The [Message] object containing message data. + * @param sentBySelf Boolean indicating whether the message was sent by the current user. + * @param messageContent Composable lambda for rendering the message content. + * @param sender Name of the sender of the message. + * @param modifier Modifier to apply to the message bubble. + */ @Composable fun MessageBubble( chatMessage: Message, @@ -510,4 +540,3 @@ fun MessageBubble( } } } - diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreen.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreen.kt index 371fe36f1..4af4200a9 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreen.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ConversationsHomeScreen.kt @@ -32,6 +32,12 @@ import com.greybox.projectmesh.messaging.ui.viewmodels.ConversationsHomeScreenVi import com.greybox.projectmesh.messaging.utils.MessageUtils import org.kodein.di.compose.localDI +/** + * Main Composable for the Conversations Home screen. + * + * @param onConversationSelected Callback when a conversation is selected. + * @param viewModel [ConversationsHomeScreenViewModel] providing the UI state. + */ @Composable fun ConversationsHomeScreen( onConversationSelected: (String) -> Unit, @@ -88,6 +94,12 @@ fun ConversationsHomeScreen( } } +/** + * Displays a scrollable list of conversations. + * + * @param conversations List of [Conversation] objects to display. + * @param onConversationClick Callback when a conversation item is clicked. + */ @Composable fun ConversationsList( conversations: List, @@ -110,6 +122,12 @@ fun ConversationsList( } } +/** + * Displays an individual conversation item with avatar, status, last message, and unread count. + * + * @param conversation The [Conversation] to display. + * @param onClick Callback for when the conversation item is clicked. + */ @Composable fun ConversationItem( conversation: Conversation, @@ -276,6 +294,9 @@ fun ConversationItem( } } +/** + * Displays a placeholder view when there are no conversations. + */ @Composable fun EmptyConversationsView() { Column( @@ -310,6 +331,12 @@ fun EmptyConversationsView() { } } +/** + * Displays an error view with retry button when conversation loading fails. + * + * @param errorMessage The error message to display. + * @param onRetry Callback triggered when retry button is pressed. + */ @Composable fun ErrorView( errorMessage: String, @@ -359,4 +386,4 @@ fun ErrorView( Text("Retry") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModel.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModel.kt index 68448f703..0f52fe6f7 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModel.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ChatScreenViewModel.kt @@ -38,10 +38,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withTimeoutOrNull import java.net.URI +/** + * ViewModel for the Chat Screen. + * + * Responsible for managing chat messages, device status, and conversation information. + * + * @param di Dependency Injection container to provide required services and repositories. + * @param savedStateHandle Handles saved state, including virtualAddress and conversationId. + */ class ChatScreenViewModel( di: DI, savedStateHandle: SavedStateHandle ) : ViewModel() { + private val virtualAddress: InetAddress = savedStateHandle.get("virtualAddress")!! // _uiState will be updated whenever there is a change in the UI state @@ -78,18 +87,15 @@ class ChatScreenViewModel( //Log.d("ChatDebug", "GOT CONVERSATION ID FROM SAVED STATE: $savedConversationId") private val conversationId = passedConversationId ?: - ConversationUtils.createConversationId(localUuid, userUuid) + ConversationUtils.createConversationId(localUuid, userUuid) private val chatName = savedConversationId ?: conversationId //Log.d("ChatDebug", "USING CHAT NAME: $chatName (saved: $savedConversationId, generated: $conversationId)") - - private val addressDotNotation = virtualAddress.requireAddressAsInt().addressToDotNotation() private val conversationRepository: ConversationRepository by di.instance() - private val _uiState = MutableStateFlow( ChatScreenModel( deviceName = deviceName, @@ -254,7 +260,13 @@ class ChatScreenViewModel( } } - + /** + * Sends a chat message to a virtual device. + * + * @param virtualAddress IP address of the target device. + * @param message Message content as String. + * @param file Optional file attachment as [URI]. + */ fun sendChatMessage( virtualAddress: InetAddress, message: String, @@ -341,7 +353,13 @@ class ChatScreenViewModel( } } - //handles outgoing file transfer to fix unresolved reference error crash + /** + * Adds an outgoing file transfer for a given device. + * + * @param fileUri [Uri] of the file to send. + * @param toAddress IP address of the target device. + * @return [OutgoingTransferInfo] containing details of the transfer. + */ fun addOutgoingTransfer(fileUri: Uri, toAddress: InetAddress): OutgoingTransferInfo { return appServer.addOutgoingTransfer(fileUri, toAddress) } diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModel.kt b/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModel.kt index 9fd2933e1..7fcf248c9 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModel.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/ui/viewmodels/ConversationsHomeScreenViewModel.kt @@ -17,6 +17,14 @@ import kotlinx.coroutines.launch import org.kodein.di.DI import org.kodein.di.instance +/** + * ViewModel for the Conversations Home Screen. + * + * Manages the list of conversations, updates device online/offline statuses, + * and provides functions for refreshing and marking conversations as read. + * + * @param di Dependency Injection container to provide required repositories and settings. + */ class ConversationsHomeScreenViewModel( di: DI ) : ViewModel() { @@ -88,7 +96,6 @@ class ConversationsHomeScreenViewModel( } } - private fun loadConversations() { viewModelScope.launch { try { @@ -139,12 +146,20 @@ class ConversationsHomeScreenViewModel( } } - //function to refresh conversations manually + /** + * Refreshes the conversations list manually. + * + * Reloads the conversations from the repository. + */ fun refreshConversations(){ loadConversations() } - //Function to mark a conversation as read + /** + * Marks a conversation as read. + * + * @param conversationId The ID of the conversation to mark as read. + */ fun markConversationAsRead(conversationId: String) { viewModelScope.launch { try { @@ -154,4 +169,4 @@ class ConversationsHomeScreenViewModel( } } } -} \ 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 9000bc113..0323b054b 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 @@ -6,45 +6,84 @@ import android.util.Log * Centralized logging utility for the app. * Provides consistent logging with standardized tags and can be disabled in production. */ - 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" + } + + /** + * Logs a debug-level message. + * + * @param tag The log tag used to identify the source. + * @param message The message to log. + */ fun d(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.d("$TAG_PREFIX$tag", message) + Log.d(buildTag(tag), message) } } + /** + * Logs an info-level message. + * + * @param tag The log tag used to identify the source. + * @param message The message to log. + */ fun i(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.i("$TAG_PREFIX$tag", message) + Log.i(buildTag(tag), message) } } + /** + * Logs a warning-level message. + * + * @param tag The log tag used to identify the source. + * @param message The message to log. + */ fun w(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.w("$TAG_PREFIX$tag", message) + Log.w(buildTag(tag), message) } } + /** + * Logs an error-level message. + * + * @param tag The log tag used to identify the source. + * @param message The message to log. + * @param throwable Optional exception to include in the log output. + */ fun e(tag: String, message: String, throwable: Throwable? = null) { if (LOGGING_ENABLED) { if (throwable != null) { - Log.e("$TAG_PREFIX$tag", message, throwable) + Log.e(buildTag(tag), message, throwable) } else { - Log.e("$TAG_PREFIX$tag", message) + Log.e(buildTag(tag), message) } } } - // Log important events that should be visible even in production + /** + * Logs high-importance errors that should always appear even in production. + * + * @param tag The log tag used to identify the source. + * @param message The message to log. + * @param throwable Optional exception to include in the log output. + */ fun critical(tag: String, message: String, throwable: Throwable? = null) { + val criticalTag = buildCriticalTag(tag) if (throwable != null) { - Log.e("$TAG_PREFIX${tag}_CRITICAL", message, throwable) + Log.e(criticalTag, message, throwable) } else { - Log.e("$TAG_PREFIX${tag}_CRITICAL", message) + Log.e(criticalTag, 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 a2bb342f4..d93a61c96 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 @@ -12,15 +12,35 @@ import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.instance +/** + * Utility responsible for migrating legacy messages to the newer + * conversation-ID–based chat naming format. + * + * It inspects all existing messages, infers correct conversation IDs, + * and rewrites their `chat` field when necessary. + * + * This allows older installations to transition cleanly to the + * standardized conversation model. + */ class MessageMigrationUtils( override val di: DI ): DIAware { + private val db: MeshDatabase by di.instance() /** - * Migrates existing messages to use in converstion IDs as chatNames + * Migrates all historical messages so that each message's `chat` + * value follows the modern conversation ID format. + * + * Steps performed: + * - Loads all messages + * - Groups them by legacy chat name + * - Determines correct UUID association for each chat group + * - Generates a conversation ID using local + remote UUIDs + * - Rewrites messages with updated chat names + * + * Errors are logged but do not stop the migration process. */ - suspend fun migrateMessagesToChatIds() { withContext(Dispatchers.IO){ try { @@ -93,7 +113,17 @@ class MessageMigrationUtils( } } - private fun createConversationId(uuid1: String, uuid2: String): String { + /** + * Generates a consistent conversation ID using two UUIDs. + * + * Special cases: + * - Test device UUIDs map to fixed, readable conversation IDs. + * + * @param uuid1 Local UUID. + * @param uuid2 Remote UUID. + * @return A stable, sorted, hyphen-joined conversation ID. + */ + internal fun createConversationId(uuid1: String, uuid2: String): String { // Special cases for test devices if (uuid2 == "test-device-uuid") { return "local-user-test-device-uuid" @@ -103,4 +133,4 @@ class MessageMigrationUtils( } return listOf(uuid1, uuid2).sorted().joinToString("-") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageUtils.kt b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageUtils.kt index af2d20275..e9db5b825 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageUtils.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageUtils.kt @@ -1,13 +1,34 @@ package com.greybox.projectmesh.messaging.utils +/** + * Utility functions for formatting message metadata and generating stable + * chat identifiers used throughout the messaging system. + */ object MessageUtils { + + /** + * Formats a Unix timestamp into a human-readable time string. + * + * @param timestamp The timestamp in milliseconds. + * @return A formatted time string in `"HH:mm"` format. + */ fun formatTimestamp(timestamp: Long): String { //Adding timestamp formatting logic return java.text.SimpleDateFormat("HH:mm").format(timestamp) } + /** + * Generates a stable, deterministic chat ID from two user identifiers. + * + * The two identifiers are sorted alphabetically so both users + * will always compute the same ID for the same pair. + * + * @param sender The identifier of the sender. + * @param receiver The identifier of the receiver. + * @return A hyphen-joined chat ID such as `"userA-userB"`. + */ fun generateChatId(sender: String, receiver: String): String { //Create a consistent chat ID for two users return listOf(sender, receiver).sorted().joinToString("-") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavHost.kt b/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavHost.kt index 959ed9433..a5ba77f43 100644 --- a/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavHost.kt +++ b/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavHost.kt @@ -13,6 +13,13 @@ import androidx.compose.ui.unit.sp import androidx.navigation.compose.rememberNavController import com.greybox.projectmesh.R +/** + * Represents a single item inside the bottom navigation bar. + * + * @param route The navigation route associated with this item. + * @param label The text label shown beneath the icon. + * @param icon The vector icon displayed for this item. + */ data class NavigationItem( val route: String, val label: String, @@ -20,6 +27,9 @@ data class NavigationItem( ) //Preview is to show the bottom navigation bar in the preview and notice what it looks like +/** + * Preview for displaying the bottom navigation bar inside the design tools. + */ @Preview(showBackground = true) @Composable fun BottomNavigationBarPreview() { @@ -27,6 +37,14 @@ fun BottomNavigationBarPreview() { BottomNavigationBar(navController = navController) } +/** + * Displays the application's bottom navigation bar. + * + * Automatically highlights the currently selected destination and handles + * navigation state restoration and avoiding duplicate destinations. + * + * @param navController The controller used to perform navigation actions. + */ @Composable fun BottomNavigationBar(navController: NavHostController) { val items = listOf( @@ -66,8 +84,8 @@ fun BottomNavigationBar(navController: NavHostController) { launchSingleTop = true } }, - + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavItem.kt b/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavItem.kt index 83e8b9351..437cd8fff 100644 --- a/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavItem.kt +++ b/app/src/main/java/com/greybox/projectmesh/navigation/BottomNavItem.kt @@ -5,13 +5,31 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.* import androidx.compose.ui.graphics.vector.ImageVector - -sealed class BottomNavItem(val route: String, val title: String, val icon: ImageVector){ +/** + * Represents a single item in the bottom navigation bar. + * + * Each item has a route (for navigation), a title (displayed as text), + * and an icon (displayed visually in the bar). + */ +sealed class BottomNavItem(val route: String, val title: String, val icon: ImageVector) { + /** Home tab item */ data object Home : BottomNavItem("home", "Home", Icons.Default.Home) + + /** Network tab item */ data object Network : BottomNavItem("network", "Network", Icons.Default.Wifi) + + /** Send tab item */ data object Send : BottomNavItem("send", "Send", Icons.AutoMirrored.Filled.Send) + + /** Receive tab item */ data object Receive : BottomNavItem("receive", "Receive", Icons.Default.Download) + + /** Log tab item */ data object Log: BottomNavItem("log", "Log", Icons.Default.History) + + /** Settings tab item */ data object Settings : BottomNavItem("settings", "Settings", Icons.Default.Settings) + + /** Chat tab item */ data object Chat : BottomNavItem("chat", "Chat", Icons.Default.ChatBubble) -} \ No newline at end of file +} 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/testing/TestDeviceEntry.kt b/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceEntry.kt index d78b4a0c1..8a80ce495 100644 --- a/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceEntry.kt +++ b/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceEntry.kt @@ -9,14 +9,25 @@ import java.net.DatagramSocket import java.net.InetAddress import java.util.concurrent.Executors +/** + * Utility class to create test device entries for the mesh network. + * + * This simulates a device with a virtual node, logger, and mock network socket, + * allowing for testing without real devices. + */ class TestDeviceEntry { companion object { - // Create a test logger + /** Test logger used for capturing logs during testing */ private val testLogger = TestMNetLogger() + /** + * Creates a simulated test device entry. + * + * @return a pair containing the device's integer address and its LastOriginatorMessage + */ fun createTestEntry(): Pair { try { - //convert string IP to bytes + // Convert the string IP to a byte array val testAddressBytes = TestDeviceService.TEST_DEVICE_IP .split(".") .map { it.toInt().toByte() } @@ -24,7 +35,7 @@ class TestDeviceEntry { val testAddress = InetAddress.getByAddress(testAddressBytes) - // Convert IP address to Int manually + // Convert IP address bytes to an Int manually val testAddressInt = testAddressBytes.foldIndexed(0) { index, acc, byte -> acc or ((byte.toInt() and 0xFF) shl (24 - (index * 8))) } @@ -32,8 +43,7 @@ class TestDeviceEntry { Log.d("TestDeviceEntry", "Creating test entry with IP: ${TestDeviceService.TEST_DEVICE_IP}") Log.d("TestDeviceEntry", "Test address as int: $testAddressInt") - - //create basic MmcpOriginatorMessage + // Create a basic MmcpOriginatorMessage val mockOriginatorMessage = MmcpOriginatorMessage( messageId = 1, pingTimeSum = 50.toShort(), @@ -41,10 +51,10 @@ class TestDeviceEntry { sentTime = System.currentTimeMillis() ) - //create a virtual router for testing + // Create a virtual router for testing val testRouter = TestVirtualRouter() - //create a mock VirtualNodeDatagramSocket with our test router + // Create a mock VirtualNodeDatagramSocket using our test router val mockSocket = VirtualNodeDatagramSocket( socket = DatagramSocket(), ioExecutorService = Executors.newSingleThreadExecutor(), @@ -53,7 +63,7 @@ class TestDeviceEntry { logger = testLogger ) - // Create LastOriginatorMessage with all required parameters + // Build the LastOriginatorMessage object val lastOriginatorMessage = VirtualNode.LastOriginatorMessage( originatorMessage = mockOriginatorMessage, timeReceived = System.currentTimeMillis(), @@ -71,4 +81,4 @@ class TestDeviceEntry { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceService.kt b/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceService.kt index 34754bebb..86579c6e1 100644 --- a/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceService.kt +++ b/app/src/main/java/com/greybox/projectmesh/testing/TestDeviceService.kt @@ -7,23 +7,36 @@ import com.greybox.projectmesh.messaging.data.entities.Message import kotlinx.coroutines.runBlocking import java.net.InetAddress +/** + * Service for managing test devices in the mesh network. + * + * Handles initialization of both online and offline test devices, + * provides utility methods for identifying test devices, and + * generating echo responses for testing purposes. + */ class TestDeviceService { companion object { + /** IP and name for the online test device */ const val TEST_DEVICE_IP = "192.168.0.99" const val TEST_DEVICE_NAME = "Test Echo Device (Online)" + + /** IP and name for the offline test device */ const val TEST_DEVICE_IP_OFFLINE = "192.168.0.98" const val TEST_DEVICE_NAME_OFFLINE = "Test Echo Device (Offline)" private var isInitialized = false private var offlineDeviceInitialized = false + /** + * Initializes the online test device if it hasn't been set up already. + */ fun initialize() { try { if (!isInitialized) { runBlocking { val existingUser = userRepository.getUserByIp(TEST_DEVICE_IP) if (existingUser == null) { - // If there's no user with this IP, insert one with a "temp" UUID + // Insert a new user with a temporary UUID val pseudoUuid = "temp-$TEST_DEVICE_IP" userRepository.insertOrUpdateUser( uuid = pseudoUuid, @@ -31,8 +44,7 @@ class TestDeviceService { address = TEST_DEVICE_IP ) } else { - // If a user with this IP already exists, just update the name - // (keeping the same uuid and address) + // Update the name of an existing user with this IP userRepository.insertOrUpdateUser( uuid = existingUser.uuid, name = TEST_DEVICE_NAME, @@ -43,7 +55,7 @@ class TestDeviceService { isInitialized = true Log.d("TestDeviceService", "Test device initialized successfully with IP: $TEST_DEVICE_IP") - //initialize offline test device + // Initialize the offline test device initializeOfflineDevice() } } catch (e: Exception) { @@ -51,25 +63,28 @@ class TestDeviceService { } } + /** + * Initializes the offline test device if it hasn't been set up already. + */ fun initializeOfflineDevice() { try { if (!offlineDeviceInitialized) { runBlocking { val existingUser = userRepository.getUserByIp(TEST_DEVICE_IP_OFFLINE) if (existingUser == null) { - // Create a new offline test device + // Create a new offline test device with null address val pseudoUuid = "temp-offline-$TEST_DEVICE_IP_OFFLINE" userRepository.insertOrUpdateUser( uuid = pseudoUuid, name = TEST_DEVICE_NAME_OFFLINE, - address = null // NULL address means offline + address = null // null address indicates offline ) } else { - // Update existing offline device + // Update existing offline device to ensure it remains offline userRepository.insertOrUpdateUser( uuid = existingUser.uuid, name = TEST_DEVICE_NAME_OFFLINE, - address = null // Make sure it's offline + address = null ) } } @@ -81,23 +96,32 @@ class TestDeviceService { } } + /** Checks if the given address is the online test device */ fun isOnlineTestDevice(address: InetAddress): Boolean { return address.hostAddress == TEST_DEVICE_IP } + /** Checks if the given address is the offline test device */ fun isOfflineTestDevice(address: InetAddress): Boolean { return address.hostAddress == TEST_DEVICE_IP_OFFLINE } - + /** Returns the InetAddress of the online test device */ fun getTestDeviceAddress(): InetAddress { return InetAddress.getByName(TEST_DEVICE_IP) } + /** Checks if the given address matches the online test device */ fun isTestDevice(address: InetAddress): Boolean { return address.hostAddress == TEST_DEVICE_IP } + /** + * Generates an echo response message for testing purposes. + * + * @param originalMessage the original message to echo + * @return a new Message object containing the echo content + */ fun createEchoResponse(originalMessage: Message): Message { return Message( id = 0, @@ -108,4 +132,4 @@ class TestDeviceService { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/testing/TestMNetLogger.kt b/app/src/main/java/com/greybox/projectmesh/testing/TestMNetLogger.kt index 2aabc3bef..98a59c65a 100644 --- a/app/src/main/java/com/greybox/projectmesh/testing/TestMNetLogger.kt +++ b/app/src/main/java/com/greybox/projectmesh/testing/TestMNetLogger.kt @@ -3,7 +3,21 @@ package com.greybox.projectmesh.testing import android.util.Log import com.ustadmobile.meshrabiya.log.MNetLogger +/** + * Logger implementation for test devices. + * + * Redirects all log messages to Android's Log system with a fixed "TestDevice" tag. + * Supports both direct message strings and lambda message providers. + */ class TestMNetLogger : MNetLogger() { + + /** + * Logs a message and optional exception with a given priority. + * + * @param priority the log priority (Log.VERBOSE, Log.DEBUG, etc.) + * @param message the message to log + * @param exception optional exception to log + */ override fun invoke(priority: Int, message: String, exception: Exception?) { Log.println(priority, "TestDevice", message) exception?.let { @@ -11,10 +25,17 @@ class TestMNetLogger : MNetLogger() { } } + /** + * Logs a lazily evaluated message and optional exception with a given priority. + * + * @param priority the log priority (Log.VERBOSE, Log.DEBUG, etc.) + * @param message lambda returning the message to log + * @param exception optional exception to log + */ override fun invoke(priority: Int, message: () -> String, exception: Exception?) { Log.println(priority, "TestDevice", message()) exception?.let { Log.println(priority, "TestDevice", it.toString()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/testing/TestVirtualRouter.kt b/app/src/main/java/com/greybox/projectmesh/testing/TestVirtualRouter.kt index 3b843098f..19c2ef7cf 100644 --- a/app/src/main/java/com/greybox/projectmesh/testing/TestVirtualRouter.kt +++ b/app/src/main/java/com/greybox/projectmesh/testing/TestVirtualRouter.kt @@ -7,32 +7,70 @@ import com.ustadmobile.meshrabiya.vnet.socket.ChainSocketNextHop import java.net.DatagramPacket import java.net.InetAddress +/** + * Test implementation of a VirtualRouter for use with test devices. + * + * Provides dummy routing behavior and predictable network parameters for testing purposes. + */ class TestVirtualRouter : VirtualRouter { + + /** Fixed test device address */ override val address: InetAddress = InetAddress.getByName(TestDeviceService.TEST_DEVICE_IP) + + /** Fixed port for local datagram operations */ override val localDatagramPort: Int = 4242 + + /** Fixed network prefix length for testing */ override val networkPrefixLength: Int = 16 - override fun route(packet: VirtualPacket, datagramPacket: DatagramPacket?, virtualNodeDatagramSocket: com.ustadmobile.meshrabiya.vnet.VirtualNodeDatagramSocket?) { + /** + * Route a packet. + * + * No-op implementation for test purposes. + */ + override fun route( + packet: VirtualPacket, + datagramPacket: DatagramPacket?, + virtualNodeDatagramSocket: com.ustadmobile.meshrabiya.vnet.VirtualNodeDatagramSocket? + ) { // no-op for test implementation } - override fun allocateUdpPortOrThrow(virtualDatagramSocketImpl: com.ustadmobile.meshrabiya.vnet.datagram.VirtualDatagramSocketImpl, portNum: Int): Int { + /** + * Allocate a UDP port or throw exception if unavailable. + * + * Always returns the requested port number in the test implementation. + */ + override fun allocateUdpPortOrThrow( + virtualDatagramSocketImpl: com.ustadmobile.meshrabiya.vnet.datagram.VirtualDatagramSocketImpl, + portNum: Int + ): Int { return portNum } + /** + * Deallocate a port. + * + * No-op implementation for test purposes. + */ override fun deallocatePort(protocol: Protocol, portNum: Int) { // no-op for test implementation } + /** + * Look up the next hop for a chain socket. + * + * Returns a dummy next hop with isFinalDest = true for testing. + */ override fun lookupNextHopForChainSocket(address: InetAddress, port: Int): ChainSocketNextHop { - //Return dummy next hop for testing with all required parameters return ChainSocketNextHop( address = address, port = port, isFinalDest = true, - network = null //For testing purposes, we can pass null for the network + network = null // network is null for test purposes ) } + /** Returns a constant MMCP message ID for testing */ override fun nextMmcpMessageId(): Int = 1 -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/ui/theme/CustomButton.kt b/app/src/main/java/com/greybox/projectmesh/ui/theme/CustomButton.kt index f08814f39..6a7c05451 100644 --- a/app/src/main/java/com/greybox/projectmesh/ui/theme/CustomButton.kt +++ b/app/src/main/java/com/greybox/projectmesh/ui/theme/CustomButton.kt @@ -27,7 +27,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay -// This is a pre-defined button with white background and black text +/** + * A transparent button with white background and black text. + * Optional rounded corners and full-width by default. + */ @Composable fun TransparentButton( onClick: () -> Unit, @@ -38,35 +41,46 @@ fun TransparentButton( Button( onClick = onClick, colors = ButtonDefaults.buttonColors( - containerColor = Color.White, // Background color - contentColor = Color.Black // Text color + containerColor = Color.White, // White background + contentColor = Color.Black // Black text ), border = BorderStroke(1.dp, Color.Black), // Black border - shape = RoundedCornerShape(8.dp), // Optional: Rounded corners - modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), // Rounded corners + modifier = modifier.fillMaxWidth(), // Fill max width by default enabled = enabled ) { Text(text = text) } } +/** + * A gradient button with press animation. + * + * @param text The label for the button. + * @param gradientColors Colors to use for horizontal gradient background. + * @param textColor Color of the text. + * @param maxWidth Maximum width of the button. + * @param onClick Action to perform when the button is clicked. + */ @Composable fun GradientButton( text: String, modifier: Modifier = Modifier, - gradientColors: List = listOf(Color(0xFF4CAF50), Color(0xFF81C784)), // Default gradient colors + gradientColors: List = listOf(Color(0xFF4CAF50), Color(0xFF81C784)), // Default gradient textColor: Color = Color.White, maxWidth: Dp = 120.dp, onClick: () -> Unit ) { var isPressed by remember { mutableStateOf(false) } - val scale by animateFloatAsState(if (isPressed) 0.85f else 1f) // Scale down when pressed + val scale by animateFloatAsState(if (isPressed) 0.85f else 1f) // Scale down on press + LaunchedEffect(isPressed) { if (isPressed) { - delay(100) // Wait for 100 ms + delay(100) // Short delay to show pressed effect isPressed = false } } + Box( modifier = modifier .scale(scale) @@ -75,14 +89,14 @@ fun GradientButton( brush = Brush.horizontalGradient(gradientColors), shape = RoundedCornerShape(12.dp) ) - .height(50.dp) // Height of the button - .widthIn(min = 120.dp, max = maxWidth) // Width of the button + .height(50.dp) + .widthIn(min = 120.dp, max = maxWidth) .padding(horizontal = 16.dp) .clickable { isPressed = true onClick() }, - contentAlignment = Alignment.Center // Center content in the box + contentAlignment = Alignment.Center // Center the text ) { Text( text = text, @@ -90,42 +104,49 @@ fun GradientButton( fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, // Truncate text if it overflows + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } } +/** + * A full-width gradient button with press animation. + * + * Similar to GradientButton but fills the available width. + */ @Composable fun GradientLongButton( text: String, modifier: Modifier = Modifier, - gradientColors: List = listOf(Color(0xFF4CAF50), Color(0xFF81C784)), // Default gradient colors + gradientColors: List = listOf(Color(0xFF4CAF50), Color(0xFF81C784)), // Default gradient textColor: Color = Color.White, onClick: () -> Unit ) { var isPressed by remember { mutableStateOf(false) } - val scale by animateFloatAsState(if (isPressed) 0.85f else 1f) // Scale down when pressed + val scale by animateFloatAsState(if (isPressed) 0.85f else 1f) // Scale down on press + LaunchedEffect(isPressed) { if (isPressed) { - delay(100) // Wait for 100 ms + delay(100) isPressed = false } } + Box( modifier = modifier - .fillMaxWidth() + .fillMaxWidth() // Fill the full width .scale(scale) - .shadow(8.dp, RoundedCornerShape(12.dp)) // Shadow effect + .shadow(8.dp, RoundedCornerShape(12.dp)) .background( brush = Brush.horizontalGradient(gradientColors), shape = RoundedCornerShape(12.dp) ) - .height(50.dp) // Height of the button + .height(50.dp) .clickable { isPressed = true onClick() }, - contentAlignment = Alignment.Center // Center content in the box + contentAlignment = Alignment.Center ) { Text( text = text, @@ -133,7 +154,7 @@ fun GradientLongButton( fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, // Truncate text if it overflows + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } } diff --git a/app/src/main/java/com/greybox/projectmesh/ui/theme/Theme.kt b/app/src/main/java/com/greybox/projectmesh/ui/theme/Theme.kt index 3232e0b31..680c8a13a 100644 --- a/app/src/main/java/com/greybox/projectmesh/ui/theme/Theme.kt +++ b/app/src/main/java/com/greybox/projectmesh/ui/theme/Theme.kt @@ -5,7 +5,9 @@ import androidx.compose.runtime.Composable import androidx.compose.material3.* import androidx.compose.ui.graphics.Color -// Define the color schemes for light and dark themes +/** + * Dark color scheme for the app. + */ private val DarkColorScheme = darkColorScheme( primary = Color(0xFFBB86FC), secondary = Color(0xFF03DAC5), @@ -17,6 +19,9 @@ private val DarkColorScheme = darkColorScheme( onSurface = Color.White ) +/** + * Light color scheme for the app. + */ private val LightColorScheme = lightColorScheme( primary = Color(0xFF6200EE), secondary = Color(0xFF03DAC5), @@ -28,23 +33,33 @@ private val LightColorScheme = lightColorScheme( onSurface = Color.Black ) +/** + * Enum to represent the app's theme choice. + */ enum class AppTheme { SYSTEM, LIGHT, DARK } +/** + * Apply ProjectMesh theme with the chosen AppTheme. + * + * @param appTheme The selected theme (System, Light, Dark) + * @param content The composable content to wrap with this theme + */ @Composable fun ProjectMeshTheme( appTheme: AppTheme, content: @Composable () -> Unit ) { val darkTheme = when (appTheme) { - AppTheme.SYSTEM -> isSystemInDarkTheme() + AppTheme.SYSTEM -> isSystemInDarkTheme() // Follow system setting AppTheme.LIGHT -> false AppTheme.DARK -> true } + MaterialTheme( colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, - content = content, - typography = Typography, + typography = Typography, // Apply predefined typography + content = content ) } 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/user/UserDao.kt b/app/src/main/java/com/greybox/projectmesh/user/UserDao.kt index 82f1c8de3..e2e94383e 100644 --- a/app/src/main/java/com/greybox/projectmesh/user/UserDao.kt +++ b/app/src/main/java/com/greybox/projectmesh/user/UserDao.kt @@ -6,25 +6,69 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +/** + * Data Access Object for user-related database operations. + */ @Dao interface UserDao { + + /** + * Get a user by their UUID. + * + * @param uuid The UUID of the user + * @return The matching UserEntity or null if not found + */ @Query("SELECT * FROM users WHERE uuid = :uuid LIMIT 1") suspend fun getUserByUuid(uuid: String): UserEntity? + /** + * Insert a new user into the database. + * Replaces existing entry if there is a conflict. + * + * @param user The user entity to insert + */ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUser(user: UserEntity) + /** + * Update an existing user in the database. + * + * @param user The user entity to update + */ @Update suspend fun updateUser(user: UserEntity) + /** + * Check if a user with a given UUID exists. + * + * @param uuid The UUID to check + * @return True if a user exists, false otherwise + */ @Query("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = :uuid)") suspend fun hasWithID(uuid: String): Boolean + /** + * Get a user by their IP address. + * + * @param ip The IP address of the user + * @return The matching UserEntity or null if not found + */ @Query("SELECT * FROM users WHERE address = :ip LIMIT 1") suspend fun getUserByIp(ip: String): UserEntity? + + /** + * Get all users with a non-null address (i.e., currently connected users). + * + * @return List of connected users + */ @Query("SELECT * FROM users WHERE address IS NOT NULL") suspend fun getAllConnectedUsers(): List + /** + * Get all users in the database. + * + * @return List of all users + */ @Query("SELECT * FROM users") suspend fun getAllUsers(): List -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/user/UserEntity.kt b/app/src/main/java/com/greybox/projectmesh/user/UserEntity.kt index 9b4e27dd8..b1455eecb 100644 --- a/app/src/main/java/com/greybox/projectmesh/user/UserEntity.kt +++ b/app/src/main/java/com/greybox/projectmesh/user/UserEntity.kt @@ -3,11 +3,20 @@ package com.greybox.projectmesh.user import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.serialization.Serializable + +/** + * Represents a user in the system. + * + * @property uuid Unique identifier for the user + * @property name Display name of the user + * @property address Optional network address (null if offline) + * @property lastSeen Optional timestamp of the last time the user was seen + */ @Serializable @Entity(tableName = "users") data class UserEntity( @PrimaryKey val uuid: String, val name: String, - val address: String? = null, // Default value provided - val lastSeen: Long? = null -) \ No newline at end of file + val address: String? = null, // Optional: null if offline + val lastSeen: Long? = null // Optional: timestamp in milliseconds +) diff --git a/app/src/main/java/com/greybox/projectmesh/user/UserRepository.kt b/app/src/main/java/com/greybox/projectmesh/user/UserRepository.kt index 2b11edd51..4a19fb3d6 100644 --- a/app/src/main/java/com/greybox/projectmesh/user/UserRepository.kt +++ b/app/src/main/java/com/greybox/projectmesh/user/UserRepository.kt @@ -2,8 +2,20 @@ package com.greybox.projectmesh.user import android.util.Log +/** + * Repository layer that wraps UserDao calls and applies simple business logic. + * + * @property userDao Data access object for interacting with the UserEntity table + */ class UserRepository(private val userDao: UserDao) { + /** + * Inserts a new user or updates an existing one. + * + * @param uuid Unique identifier for the user + * @param name Display name of the user + * @param address Optional network address + */ suspend fun insertOrUpdateUser(uuid: String, name: String, address: String?) { val existing = userDao.getUserByUuid(uuid) if (existing == null) { @@ -26,23 +38,44 @@ class UserRepository(private val userDao: UserDao) { } } + /** + * Retrieves a user by their IP address. + * + * @param ip IP address to search by + * @return Matching UserEntity or null + */ suspend fun getUserByIp(ip: String): UserEntity? { return userDao.getUserByIp(ip) } + + /** + * Retrieves a user by their UUID. + */ suspend fun getUser(uuid: String): UserEntity? { return userDao.getUserByUuid(uuid) } + + /** + * Retrieves all users that have a non-null address, + * meaning users currently considered connected. + */ suspend fun getAllConnectedUsers(): List { return userDao.getAllConnectedUsers() } + /** + * Retrieves all users stored in the database. + */ suspend fun getAllUsers(): List { return userDao.getAllUsers() } + /** + * Checks if a user exists by UUID. + */ suspend fun hasUser(uuid: String): Boolean { return userDao.hasWithID(uuid) } // Add more methods as needed -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/util/NotificationHelper.kt b/app/src/main/java/com/greybox/projectmesh/util/NotificationHelper.kt index ca9b8d53d..768b88e8c 100644 --- a/app/src/main/java/com/greybox/projectmesh/util/NotificationHelper.kt +++ b/app/src/main/java/com/greybox/projectmesh/util/NotificationHelper.kt @@ -11,11 +11,22 @@ import com.greybox.projectmesh.MainActivity import com.greybox.projectmesh.R import com.greybox.projectmesh.navigation.BottomNavItem +/** + * Utility object for creating and showing system notifications related to file receiving. + */ object NotificationHelper { + + // Notification channel identifiers private const val CHANNEL_ID = "file_receive_channel" private const val CHANNEL_NAME = "File Receive Notifications" + /** + * Creates the notification channel if the device runs Android O or higher. + * + * @param context Application context used to access the system NotificationManager + */ fun createNotificationChannel(context: Context) { + //noinspection ObsoleteSdkInt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, @@ -29,18 +40,32 @@ object NotificationHelper { } } + /** + * Displays a notification that informs the user a file has been received. + * Tapping the notification opens the Receive screen inside MainActivity. + * + * @param context Context for creating intents and notifications + * @param fileName Name of the received file to display in the message + */ fun showFileReceivedNotification(context: Context, fileName: String) { + + // Intent that navigates user into MainActivity → Receive screen val intent = Intent(context, MainActivity::class.java).apply { action = "OPEN_RECEIVE_SCREEN" putExtra("navigateTo", BottomNavItem.Receive.route) // Set target screen - putExtra("from_notification", true) // Tell MainActivity to skip permission requests + putExtra("from_notification", true) // Skip permission prompt when opened from notif flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } + // PendingIntent for launching the activity from the notification val pendingIntent = PendingIntent.getActivity( - context, 1003, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + context, + 1003, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Construct the actual notification UI val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle("File Received") @@ -51,7 +76,9 @@ object NotificationHelper { .setAutoCancel(true) .build() - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + // Trigger the notification + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(1003, notification) } } 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 abe576c77..56c307a50 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.DI import org.kodein.di.instance import java.net.InetAddress + data class SelectDestNodeScreenModel( val allNodes: Map = emptyMap(), val uris: List = emptyList(), diff --git a/app/src/main/java/com/greybox/projectmesh/views/RequestPermissionScreen.kt b/app/src/main/java/com/greybox/projectmesh/views/RequestPermissionScreen.kt index 8fb271424..739e35463 100644 --- a/app/src/main/java/com/greybox/projectmesh/views/RequestPermissionScreen.kt +++ b/app/src/main/java/com/greybox/projectmesh/views/RequestPermissionScreen.kt @@ -64,7 +64,11 @@ fun RequestPermissionsScreen(skipPermissions: Boolean) { if (currentStep == 6) return@LaunchedEffect when (currentStep) { 0 -> { // Request Nearby Wi-Fi Permission - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + //noinspection ObsoleteSdkInt + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + // changed from .M (android 6, sdk level 26) to .TIRAMISU (android 13, sdk level 33) during linting, as NEARBY_WIFI_DEVICES permission was added in Android 13. + // this may be a breaking change, next time someone with better understanding of this codebase/aspect is here, please verify this change + // made by Thalia Wood, 04/13/2026 !hasPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES)) { nearbyWifiPermissionLauncher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) } else { @@ -72,6 +76,7 @@ fun RequestPermissionsScreen(skipPermissions: Boolean) { } } 1 -> { // Request Location Permission + //noinspection ObsoleteSdkInt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) @@ -80,6 +85,7 @@ fun RequestPermissionsScreen(skipPermissions: Boolean) { } } 2 -> { // Request Notification Permission (Android 13+) + //noinspection ObsoleteSdkInt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPermission(context, Manifest.permission.POST_NOTIFICATIONS)) { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) @@ -88,6 +94,7 @@ fun RequestPermissionsScreen(skipPermissions: Boolean) { } } 3 -> { // Request Storage Permission (Android 13+ has different permissions) + //noinspection ObsoleteSdkInt val storagePermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( Manifest.permission.READ_MEDIA_IMAGES, @@ -132,6 +139,7 @@ fun hasAnyPermission(context: Context, permissions: Array): Boolean { /** Function to Check If Battery Optimization is Disabled */ fun isBatteryOptimizationDisabled(context: Context): Boolean { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + //noinspection ObsoleteSdkInt return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { powerManager.isIgnoringBatteryOptimizations(context.packageName) } else { @@ -141,6 +149,7 @@ fun isBatteryOptimizationDisabled(context: Context): Boolean { /** Function to Prompt User to Disable Battery Optimization */ fun promptDisableBatteryOptimization(context: Context) { + //noinspection ObsoleteSdkInt if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val message = SpannableString( "To ensure uninterrupted background functionality and maintain a stable connection, " + 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..331586bc1 100644 --- a/app/src/main/res/layout/activity_crash_screen.xml +++ b/app/src/main/res/layout/activity_crash_screen.xml @@ -1,17 +1,25 @@ - - + + - \ No newline at end of file + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="13dp" + android:layout_marginTop="9dp" + android:layout_marginEnd="13dp" + android:layout_marginBottom="9dp" + android:text="@string/text_view" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + 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..162f5095e 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="@string/app_name" + 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..e3a88984f 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://"开头) 发送文件 来自 @@ -50,4 +56,6 @@ 聊天 + TextView + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ec9bec573..81114a6c2 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 @@ -49,4 +63,6 @@ Registro Chat + + TextView diff --git a/app/src/main/res/values-fr-rCA/strings.xml b/app/src/main/res/values-fr-rCA/strings.xml index 1270e1527..86f26f02e 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,7 @@ Concurrence STA/AP Test manuel Réinitialiser - \ No newline at end of file + Registre + + TextView + 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..90ade5c7e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,5 @@ - - + + #FFBB86FC #FF6200EE #FF3700B3 @@ -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..a6fcee37e 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,6 @@ Chat - \ No newline at end of file + TextView + + 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/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..4e09e27f2 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt @@ -0,0 +1,44 @@ +package com.greybox.projectmesh.debug + +import android.content.Intent +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +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/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/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/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/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/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..5eefe65a3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,15 @@ 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 + id("org.jetbrains.dokka") version "2.2.0-Beta" 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(17) + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a346a0601..ed9fd02d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,56 +1,108 @@ [versions] +accompanistPermissions = "0.31.1-alpha" +acraDialog = "5.11.0" +acraHTTP = "5.11.0" +activity = "1.9.0" +activityCompose = "1.7.0" agp = "8.5.1" +appcompat = "1.6.1" +coilCompose = "1.4.0" +composeBom = "2023.08.00" +composePreviewRenderer = "0.0.1-alpha01" +composeQRPainter = "0.0.1" +constraintlayout = "2.1.4" +coreKtx = "1.10.1" +datastoreCore = "1.0.0" datastoreCoreVersion = "1.1.1" datastorePreferences = "1.1.1" -kotlin = "1.9.0" -coreKtx = "1.10.1" +datastorePreferencesCore = "1.0.0" +espressoCore = "3.5.1" +gson = "2.10.1" +ipAddress = "5.3.3" junit = "4.13.2" junitVersion = "1.1.5" -espressoCore = "3.5.1" -libMeshrabiya = "0.1-snapshot" +kodeinDIFrameworkAndroidX = "7.20.2" +kodeinDIFrameworkCompose = "7.20.2" +kotlin = "1.9.0" +kotlinxCoroutinesTest = "1.8.1" +kotlinxSerializationJSON = "1.6.3" +libMeshrabiya = "0.1d10-snapshot" +lifecycleRuntimeCompose = "2.7.0" lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.7.0" -composeBom = "2023.08.00" -constraintlayout = "2.1.4" +lifecycleViewmodelAndroid = "2.8.6" +lifecycleViewmodelCompose = "2.8.6" material = "1.11.0" -datastoreCore = "1.0.0" -lifecycleRuntimeCompose = "2.7.0" -datastorePreferencesCore = "1.0.0" -appcompat = "1.6.1" -activity = "1.9.0" -composePreviewRenderer = "0.0.1-alpha01" +material3 = "1.2.1" +materialIconsCore = "1.6.8" +materialIconsExtendedAndroid = "1.6.8" +mockk = "1.13.12" +nanoHTTPD = "2.3.1" navigationCompose = "2.7.7" -lifecycleViewmodelAndroid = "2.8.6" +okHTTP = "4.10.0" +robolectric = "4.12.2" +roomVersion = "2.6.1" +testCore = "1.6.1" +turbine = "1.1.0" +zxingAndroidEmbedded = "4.3.0" [libraries] +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } +acra-dialog = { group = "ch.acra", name = "acra-dialog", version.ref = "acraDialog" } +acra-http = { group = "ch.acra", name = "acra-http", version.ref = "acraHTTP" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } androidx-datastore-core-v111 = { module = "androidx.datastore:datastore-core", version.ref = "datastoreCoreVersion" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "datastorePreferencesCore" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-foundation = { module = "androidx.compose.foundation:foundation" } -junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "materialIconsCore" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "materialIconsExtendedAndroid" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" } +androidx-room-guava = { group = "androidx.room", name = "room-guava", version.ref = "roomVersion" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomVersion" } +androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "roomVersion" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomVersion" } +androidx-room-rxjava2 = { group = "androidx.room", name = "room-rxjava2", version.ref = "roomVersion" } +androidx-room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "roomVersion" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -lib-meshrabiya = { module = "com.github.UstadMobile.Meshrabiya:lib-meshrabiya", version.ref = "libMeshrabiya" } -androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } -androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } -androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "datastorePreferencesCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } compose-preview-renderer = { group = "com.android.tools.compose", name = "compose-preview-renderer", version.ref = "composePreviewRenderer" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } -androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" } +compose-qrpainter = { group = "com.github.yveskalume", name = "compose-qrpainter", version.ref = "composeQRPainter" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +ipaddress = { group = "com.github.seancfoley", name = "ipaddress", version.ref = "ipAddress" } +jetbrains-kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +jetbrains-kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJSON" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kodein-di-framework-android-x = { group = "org.kodein.di", name = "kodein-di-framework-android-x", version.ref = "kodeinDIFrameworkAndroidX" } +kodein-di-framework-compose = { group = "org.kodein.di", name = "kodein-di-framework-compose", version.ref = "kodeinDIFrameworkCompose" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +meshrabiya = { group = "com.github.UstadMobile.Meshrabiya", name = "lib-meshrabiya", version.ref = "libMeshrabiya" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +nanohttp = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanoHTTPD" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHTTP" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..035bbd2bf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,537 @@ +{ + "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.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "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.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "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.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "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.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "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.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} 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/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/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/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/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/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/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/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/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/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/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.puml b/uml/class-diagrams/components/views/recievescreen.puml new file mode 100644 index 000000000..b86fb39e7 --- /dev/null +++ b/uml/class-diagrams/components/views/recievescreen.puml @@ -0,0 +1,167 @@ +@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 +} + +package "DI" as DI { + class localDI + class DIContainer as "DI" +} + +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 <> +} + +' ---- Top-level wiring ---- +Views.ReceiveScreen ..> VM.ReceiveScreenViewModel : collectAsState(uiState) +Views.ReceiveScreen --> Views.HandleIncomingTransfers : passes uiState + VM callbacks + +' ---- HandleIncomingTransfers dependencies ---- +Views.HandleIncomingTransfers ..> DI.localDI : resolve DI +Views.HandleIncomingTransfers ..> Android.SharedPreferences : instance(tag="settings") +Views.HandleIncomingTransfers ..> Android.Context : LocalContext +Views.HandleIncomingTransfers ..> Android.Environment : default Downloads path fallback + +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 + +' ---- LaunchedEffect behaviors ---- +UI.LaunchedEffect ..> Android.SharedPreferences : load auto_finish +UI.LaunchedEffect ..> VM.ReceiveScreenModel : reacts to (autoFinishEnabled, incomingTransfers) + +note right of UI.LaunchedEffect +Auto-finish behavior: +If autoFinishEnabled == true: + for each transfer where status == PENDING: + onAccept(transfer) +end note + +' ---- List rendering ---- +Views.HandleIncomingTransfers --> UI.LazyColumn : render incomingTransfers +UI.LazyColumn --> UI.ListItem : per transfer + +note left of UI.ListItem +ListItem click: +- openFile(transfer) + only if transfer.file != null AND status == COMPLETED: + FileProvider.getUriForFile(...) + Intent(ACTION_VIEW) with MIME type + if resolveActivity != null: + startActivity(intent) else Toast("File cannot be opened") + catch Exception -> Toast("Error opening file: ...") +end note + +' ---- Action buttons based on status ---- +note bottom of Views.HandleIncomingTransfers +Per transfer status: +- PENDING: + show Accept (onAccept) and Decline (onDecline) +- COMPLETED: + show Delete (onDelete) and Download (onDownload(context, transfer, defaultUri)) +- DECLINED or FAILED: + show Delete (onDelete) +end note + +' ---- Download branching ---- +Views.onDownload ..> Views.saveFileToContentUri : if uriOrPath startsWith "content://" +Views.onDownload ..> Views.saveFileToDefaultPath : else + +Views.saveFileToDefaultPath ..> Views.saveFileToMediaStore : if SDK >= Q +Views.saveFileToDefaultPath ..> Android.File : else file copy to folder + +Views.saveFileToMediaStore ..> Android.MediaStore : insert into Downloads (Q+) +Views.saveFileToContentUri ..> Android.DocumentsContract : SAF createDocument + write stream + +@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