diff --git a/.copilotignore b/.copilotignore deleted file mode 100644 index 02ec3ad1d..000000000 --- a/.copilotignore +++ /dev/null @@ -1,27 +0,0 @@ -# Ignore build artifacts and generated files from Copilot indexing -# This saves context window tokens and prevents Copilot from hallucinating off of minified code. - -# Build directories -**/build/** -.gradle/ -.idea/ - -# Android generated files -**/generated/** -.cxx/ -.externalNativeBuild/ - -# Git history & worktrees -.git/ -.worktrees/ - -# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers) -core/proto/ - -# Environment and secrets -local.properties -secrets.properties -*.jks - -# Agent References (Prevents pollution of project space with external code) -.agent_refs/ diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index 5e535b215..000000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "context": { - "fileName": ["AGENTS.md", "GEMINI.md"] - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ef57ec56d..ecf5296e7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,16 +1,13 @@ name: Bug Report description: File a bug report. title: "[Bug]: " -labels: [bug] -projects: [meshtastic/Meshtastic-Android] +labels: ["bug"] +projects: ["meshtastic/Meshtastic-Android"] body: - type: markdown attributes: value: | - Thank you for helping to make Meshtastic-Android better by reporting a bug. :hugs: - - Please provide as much detail as possible so we can efficiently address your issue and avoid unnecessary back-and-forth. - + Thanks for taking the time to fill out this bug report! - type: input id: contact attributes: @@ -19,164 +16,58 @@ body: placeholder: ex. email@example.com, discord username validations: required: false - - - type: checkboxes - id: checklist - attributes: - label: "Checklist" - description: | - Please make sure you have done the following before submitting your bug report. - Bug reports that do not meet these criteria will be closed. - Requests that do not meet these criteria will be closed. - Links: - [**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues) - [**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed) - [Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing) - options: - - label: | - I am able to reproduce the bug with the latest version. - required: true - - label: | - I have updated to the latest *Alpha* firmware, and am able to reproduce the bug. Many issues are fixed quickly in alpha before the general beta release. - required: true - - label: | - I made sure that there are no existing **OPEN or CLOSED issues** which I could contribute my information to. - required: true - - label: | - I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise. - required: true - - label: | - This issue contains only one bug. - required: true - - label: | - I have read and understood the **Contribution Guidelines**. - required: true - - label: | - I agree to follow this project's Code of Conduct - required: true - - label: | - I actually read this list, and should be taken seriously. - required: false - - type: input - id: app-version - attributes: - label: Affected app version - description: | - In which Meshtastic-Android app version did you encounter the bug? - Can be seen on the bottom of the `Settings` screen in the app. - placeholder: "x.y.z-channel.x (build) flavor" - validations: - required: true - - - type: input - id: phone-os - attributes: - label: Affected Android version - description: | - With what operating system (+ version) did you encounter the bug? - placeholder: "Example: Android 14" - validations: - required: true - - - type: input - id: phone-model - attributes: - label: Affected phone model - description: | - On what phone did you encounter the bug? - placeholder: "Example: Samsung Galaxy S20 / Google Pixel 8" - validations: - required: true - - - type: input - id: hardware-model - attributes: - label: Affected node model - placeholder: "Example: Seeed T1000-E, Heltec v3, etc." - description: | - On which hardware device (Node) did you encounter the bug? - validations: - required: true - - - type: input - id: firmware-version - attributes: - label: Affected node firmware version - placeholder: "x.x.x" - description: "Which Meshtastic firmware version did you encounter the bug?" - validations: - required: true - - type: textarea - id: steps-to-reproduce + id: what-happened attributes: - label: Steps to reproduce the bug - description: | - What did you do for the bug to show up? - - If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug. - placeholder: | - 1. Go to '...' - 2. Press on '....' - 3. Swipe down to '....' + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" validations: required: true - - type: textarea - id: actual-behavior + id: app_version attributes: - label: Actual behavior - description: | - Tell us what happens with the steps given above. - + label: App Version + description: What version of Meshtastic Android are you running? + placeholder: 2.4.1 + validations: + required: true - type: textarea - id: expected-behavior + id: phone attributes: - label: Expected behavior - description: | - Tell us what you expect to happen. - + label: Phone + description: What phone/tablet and OS are you running it on? + placeholder: Pixel 8a, Android 15 + validations: + required: true - type: textarea - id: screen-media + id: radio attributes: - label: Screenshots/Screen recordings - description: | - A picture or video is worth a thousand words. - Provide as much context as possible so we know what we are looking at. - - Add screenshots or a screen recording to help explain your problem, provide detailed context to help us know what to look for. - GitHub supports uploads of images and (small) videos directly in the text box. - + label: Device + description: Which meshtastic radio device are you connecting to? + placeholder: heltec v3 + validations: + required: true + - type: textarea + id: firmware + attributes: + label: Firmware + description: Which meshtastic firmware is running on the device? + placeholder: 2.4.1.394e0e1 Beta + validations: + required: true - type: textarea id: logs attributes: label: Relevant log output - description: | - Logs help us to diagnose and reproduce issues, particularly when they are unique to your setup. - Depending on the issue, the following logs may be useful: - - App logs: This will help with most issues. If possible, provide the relevant output of: - `adb logcat -d | grep com.geeksville.mesh` - - Mesh logs: UI issues, communication issues etc. - - ` App > Settings > Advanced > Debug Panel > Export specific / export all ` - - Broader Android logs: Potentially useful if the issue goes beyond the app (connections, network etc.) - `adb logcat -d` - - Firmware logs: Useful for all connection issues with nodes - - These are piped to the USB serial port on the node, the most foolproof is to use the 'open serial' button on the web-flasher interface, and then save the output. - - The app needs to be connecting to the node via Bluetooth or Network for this to work. + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell - - - type: textarea - id: additional-information + - type: checkboxes + id: terms attributes: - label: Additional information - description: | - Any other information you'd like to include, for instance that - * the affected device is foldable or a TV - * you have disabled all animations on your device or otherwise changed system settings - * you are using battery optimization or power saving mode - * you are using a custom Android ROM or launcher - * your ferret chewed your antennas - * you are using a VPN - * you live in a faraday cage - * you dismissed all popups telling you not to do things you shouldn't do without reading them - * ... \ No newline at end of file + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://meshtastic.org/docs/legal/conduct/). + options: + - label: I agree to follow this project's Code of Conduct + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1b6deb1b1..d30ef6715 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - - name: Meshtastic Android Discussions - url: https://github.com/orgs/meshtastic/discussions/categories/android + - name: Meshtastic-Android Discussions + url: https://github.com/meshtastic/Meshtastic-Android/discussions about: Please ask and answer questions here. - name: Meshtastic Website url: https://meshtastic.org/ diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 1c7881533..d5f33e54e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,39 +1,13 @@ name: Feature Request description: File a request for new feature or functionality. title: "[Feature Request]: " -labels: [enhancement] -projects: [meshtastic/Meshtastic-Android] +labels: ["enhancement"] +projects: ["meshtastic/Meshtastic-Android"] body: - - type: checkboxes - id: checklist + - type: markdown attributes: - label: Checklist - description: | - Please make sure you have done the following before submitting your feature request. - Requests that do not meet these criteria will be closed. - Links: - [**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues) - [**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed) - [Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing) - options: - - label: - I have used the search function for **OPEN ISSUES** to see if someone else has already submitted the same feature request. - required: true - - label: | - I have **also** used the search function for **CLOSED ISSUES** to see if the feature was already implemented and is just waiting to be released, or if the feature was rejected. - required: true - - label: | - I will describe the request with as much detail as possible. - required: true - - label: | - This request contains only one single feature, **not** a list of multiple (related) features. - required: true - - label: | - I have read and understood the **Contribution Guidelines**. - required: true - - label: | - I agree to follow this project's Code of Conduct - required: true + value: | + Thanks for taking the time to fill out this feature request! - type: input id: contact attributes: @@ -43,19 +17,25 @@ body: validations: required: false - type: textarea - id: feature + id: request attributes: - label: Feature or improvement you want - description: Try to be as specific as possible. Please not only explain what the feature does, but also how. + label: Tell us your idea. + description: Tell us what you'd like the app to do. Give as much detail as you can. + placeholder: Your idea + value: "I'd like the app to..." + validations: + required: true - type: textarea - id: reason + id: logs attributes: - label: Why should this be added? - description: | - What problem does the feature solve? In what use-cases is the feature needed? - Is this supported by the firmware? Please provide links to relevant firmware issues or PRs if applicable. - - type: textarea - id: screenshots + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + id: terms attributes: - label: Screenshots / Drawings / Technical details - description: If your request is about (or includes) changing or extending the UI, describe what the UI would look like and how the user would interact with it. \ No newline at end of file + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://meshtastic.org/docs/legal/conduct/). + options: + - label: I agree to follow this project's Code of Conduct + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/zbug_report_internal.yml b/.github/ISSUE_TEMPLATE/zbug_report_internal.yml deleted file mode 100644 index 5f01a4573..000000000 --- a/.github/ISSUE_TEMPLATE/zbug_report_internal.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: Internal testing - Bug Report -description: File a bug report. -title: "[Bug]: " -labels: [bug, ch_testing] -projects: [meshtastic/Meshtastic-Android] -body: - - type: markdown - attributes: - value: | - This Bug report is only for the internal testing builds. If you are not on the list, or didn't seek one out deliberately, you should use the generic Bug Report. :hugs: - - Please provide as much detail as possible so we can efficiently reproduce the issue. - - - type: input - id: contact - attributes: - label: Contact Details - description: How can we get in touch with you if we need more info? - placeholder: ex. email@example.com, discord username - validations: - required: false - - - type: checkboxes - id: checklist - attributes: - label: "Checklist" - description: | - Please make sure you have done the following before submitting your bug report. - Bug reports that do not meet these criteria will be closed. - Requests that do not meet these criteria will be closed. - Links: - [**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues) - [**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed) - [Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing) - options: - - label: | - I am able to reproduce the bug with the latest version. - required: true - - label: | - I have updated to the latest *Alpha* firmware, and am able to reproduce the bug. Many issues are fixed quickly in alpha before the general beta release. - required: true - - label: | - I made sure that there are no existing **OPEN or CLOSED issues** which I could contribute my information to. - required: true - - label: | - I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise. - required: true - - label: | - This issue contains only one bug. - required: true - - label: | - I have read and understood the **Contribution Guidelines**. - required: true - - label: | - I agree to follow this project's Code of Conduct - required: true - - - type: input - id: app-version - attributes: - label: Affected app version - description: | - In which Meshtastic-Android app version did you encounter the bug? - Can be seen on the bottom of the `Settings` screen in the app. - placeholder: "x.y.z-channel.x (build) flavor" - validations: - required: true - - - type: input - id: phone-os - attributes: - label: Affected Android version - description: | - With what operating system (+ version) did you encounter the bug? - placeholder: "Example: Android 14" - validations: - required: true - - - type: input - id: phone-model - attributes: - label: Affected phone model - description: | - On what phone did you encounter the bug? - placeholder: "Example: Samsung Galaxy S20 / Google Pixel 8" - validations: - required: true - - - type: input - id: hardware-model - attributes: - label: Affected node model - placeholder: "Example: Seeed T1000-E, Heltec v3, etc." - description: | - On which hardware device (Node) did you encounter the bug? - validations: - required: true - - - type: input - id: firmware-version - attributes: - label: Affected node firmware version - placeholder: "x.x.x" - description: "Which Meshtastic firmware version did you encounter the bug?" - validations: - required: true - - - type: textarea - id: steps-to-reproduce - attributes: - label: Steps to reproduce the bug - description: | - What did you do for the bug to show up? - - If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug. - placeholder: | - 1. Go to '...' - 2. Press on '....' - 3. Swipe down to '....' - validations: - required: true - - - type: textarea - id: actual-behavior - attributes: - label: Actual behavior - description: | - Tell us what happens with the steps given above. - - - type: textarea - id: expected-behavior - attributes: - label: Expected behavior - description: | - Tell us what you expect to happen. - - - type: textarea - id: screen-media - attributes: - label: Screenshots/Screen recordings - description: | - A picture or video is worth a thousand words. - Provide as much context as possible so we know what we are looking at. - - Add screenshots or a screen recording to help explain your problem, provide detailed context to help us know what to look for. - GitHub supports uploads of images and (small) videos directly in the text box. - - - type: textarea - id: logs - attributes: - label: Relevant log output - description: | - Logs help us to diagnose and reproduce issues, particularly when they are unique to your setup. - Depending on the issue, the following logs may be useful: - - App logs: This will help with most issues. If possible, provide the relevant output of: - `adb logcat -d | grep com.geeksville.mesh` - - Mesh logs: UI issues, communication issues etc. - - ` App > Settings > Advanced > Debug Panel > Export specific / export all ` - - Broader Android logs: Potentially useful if the issue goes beyond the app (connections, network etc.) - `adb logcat -d` - - Firmware logs: Useful for all connection issues with nodes - - These are piped to the USB serial port on the node, the most foolproof is to use the 'open serial' button on the web-flasher interface, and then save the output. - - The app needs to be connecting to the node via Bluetooth or Network for this to work. - render: shell - - - type: textarea - id: additional-information - attributes: - label: Additional information - description: | - Any other information you'd like to include, for instance that - * the affected device is foldable or a TV - * you have disabled all animations on your device or otherwise changed system settings - * you are using battery optimization or power saving mode - * you are using a custom Android ROM or launcher - * your ferret chewed your antennas - * you are using a VPN - * you live in a faraday cage - * you dismissed all popups telling you not to do things you shouldn't do without reading them - * ... \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fde9ba798..90af25ee9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,20 +9,3 @@ - If your other co-developers have comments on your PR please tweak as needed - Do not use any external image service, just paste or drag and drop the image here and it will be uploaded automatically - Please also enable "Allow edits by maintainers". - - \ No newline at end of file diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml deleted file mode 100644 index a42959190..000000000 --- a/.github/actions/gradle-setup/action.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Gradle Setup -description: Setup Java and Gradle for KMP builds -inputs: - cache_read_only: - description: 'Whether Gradle cache is read-only' - default: 'true' - jdk_distribution: - description: 'JDK distribution (temurin or jetbrains)' - default: 'temurin' - gradle_encryption_key: - description: 'Encryption key for Gradle remote cache' - required: false -runs: - using: composite - steps: - - name: Copy CI Gradle properties - shell: bash - run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v6 - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - java-version: '21' - distribution: ${{ inputs.jdk_distribution }} - token: ${{ github.token }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-read-only: ${{ inputs.cache_read_only }} - cache-encryption-key: ${{ inputs.gradle_encryption_key }} - cache-cleanup: on-success - add-job-summary: always - gradle-home-cache-includes: | - caches - notifications - ~/.m2/repository/org/robolectric \ No newline at end of file diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties deleted file mode 100644 index e4d203ef7..000000000 --- a/.github/ci-gradle.properties +++ /dev/null @@ -1,52 +0,0 @@ -# -# CI-specific Gradle properties. -# -# This file is copied to ~/.gradle/gradle.properties by the gradle-setup -# composite action, overriding the dev-oriented values in the repo-root -# gradle.properties. Inspired by the nowinandroid & sqldelight patterns. -# - -# ── Daemon ──────────────────────────────────────────────────────────── -# Single-use CI runners never reuse a daemon, so the startup cost is pure -# overhead. Disabling it also avoids "daemon disappeared" warnings. -org.gradle.daemon=false - -# ── Memory ──────────────────────────────────────────────────────────── -# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon -# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom). -org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 -kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC - -# ── Parallelism ─────────────────────────────────────────────────────── -org.gradle.parallel=true -org.gradle.workers.max=4 - -# ── Caching & Configuration ────────────────────────────────────────── -org.gradle.caching=true -org.gradle.configuration-cache=true -org.gradle.configureondemand=false -org.gradle.vfs.watch=false -org.gradle.isolated-projects=true - -# ── Kotlin ──────────────────────────────────────────────────────────── -# Incremental compilation is wasted on fresh CI checkouts (no prior build -# state to diff against). Disabling avoids the overhead of maintaining -# incremental state that will never be reused. -kotlin.incremental=false -kotlin.code.style=official -kotlin.parallel.tasks.in.project=true - -# ── KSP ────────────────────────────────────────────────────────────── -# In CI, KSP incremental processing adds overhead without benefit (fresh -# checkouts). Keep intermodule incremental off (no prior state). -ksp.incremental=false -ksp.run.in.process=true - -# ── Android ────────────────────────────────────────────────────────── -android.experimental.lint.analysisPerComponent=true -# Disable unused build features to reduce build time -android.defaults.buildfeatures.resvalues=false -android.defaults.buildfeatures.shaders=false - -# ── Misc ───────────────────────────────────────────────────────────── -org.gradle.welcome=never diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md deleted file mode 100644 index 93c242d16..000000000 --- a/.github/copilot-commit-message-instructions.md +++ /dev/null @@ -1,27 +0,0 @@ -# GitHub Copilot Commit Message Instructions - - -You are an expert Git maintainer enforcing Conventional Commits. - - - -1. **Format:** Use the Conventional Commits format: `(): ` (Replace angle brackets with actual text, do NOT output angle brackets). -2. **Types allowed:** - - `feat` (new feature for the user, not a new feature for build script) - - `fix` (bug fix for the user, not a fix to a build script) - - `docs` (changes to the documentation) - - `style` (formatting, missing semi colons, etc; no production code change) - - `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain) - - `test` (adding missing tests, refactoring tests; no production code change) - - `chore` (updating grunt tasks etc; no production code change) -3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`). -4. **Subject line:** - - Use the imperative, present tense: "change" not "changed" nor "changes". - - Do not capitalize the first letter. - - Do not use a period (.) at the end. - - Keep it under 50 characters if possible. -5. **Body (Optional but recommended for large diffs):** - - Leave one blank line after the subject. - - Explain *why* the change was made, not just *what* changed. - - If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework". - diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index e856cbe8f..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,6 +0,0 @@ -# Meshtastic Android - GitHub Copilot Guide - -> **Note:** The canonical instructions for all AI Agents have been deduplicated. - -You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. -After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md deleted file mode 100644 index 8e79d63d2..000000000 --- a/.github/copilot-pull-request-instructions.md +++ /dev/null @@ -1,18 +0,0 @@ -# GitHub Copilot Pull Request Instructions - - -You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs. - - - -1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text. -2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`. -3. **Structured Changes:** Break down the code changes into bullet points categorized by: - - 🌟 **New Features** (UI, modules, logic) - - 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates) - - 🐛 **Bug Fixes** - - 🧹 **Chores** (Dependencies, formatting, docs) -4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone". -5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified. -6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots. - diff --git a/.github/instructions/android-source-set.instructions.md b/.github/instructions/android-source-set.instructions.md deleted file mode 100644 index 6179bc61a..000000000 --- a/.github/instructions/android-source-set.instructions.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -applyTo: "**/androidMain/**/*.kt" ---- - -# Android Source-Set Rules - -- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here. -- Do NOT put business logic here. Business logic belongs in `commonMain`. -- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`. -- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI. -- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors. diff --git a/.github/instructions/build-logic.instructions.md b/.github/instructions/build-logic.instructions.md deleted file mode 100644 index d61fa34b8..000000000 --- a/.github/instructions/build-logic.instructions.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -applyTo: "build-logic/**/*.kt" ---- - -# Build-Logic Convention Plugin Rules - -- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). -- Avoid `afterEvaluate` unless there is no viable lazy alternative. -- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones. -- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`. diff --git a/.github/instructions/ci-workflows.instructions.md b/.github/instructions/ci-workflows.instructions.md deleted file mode 100644 index 55a72b328..000000000 --- a/.github/instructions/ci-workflows.instructions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -applyTo: "**/*.yml" -excludeAgent: "code-review" ---- - -# CI Workflow Rules - -- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`). -- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values. -- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`. -- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise. -- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`. -- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners. -- Gradle-heavy jobs: use `ubuntu-24.04` runners. diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md deleted file mode 100644 index 7dac915bc..000000000 --- a/.github/instructions/kmp-common.instructions.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -applyTo: "**/commonMain/**/*.kt" ---- - -# KMP commonMain Rules - -- NEVER import `java.*` or `android.*` in `commonMain`. -- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`. -- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`. -- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`. -- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`. -- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies. -- Use `compose-multiplatform-*` catalog aliases for CMP dependencies. -- Never use plain `androidx.compose` dependencies in `commonMain`. -- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings. -- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`. -- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls. -- Check `gradle/libs.versions.toml` before adding dependencies. -- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code. -- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`. diff --git a/.github/lsp.json b/.github/lsp.json deleted file mode 100644 index 983ecf785..000000000 --- a/.github/lsp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "lspServers": { - "kotlin": { - "command": "kotlin-language-server", - "args": [], - "fileExtensions": { - ".kt": "kotlin", - ".kts": "kotlin" - } - } - } -} diff --git a/.github/meshtastic_logo.png b/.github/meshtastic_logo.png deleted file mode 100644 index 11c5db18c..000000000 Binary files a/.github/meshtastic_logo.png and /dev/null differ diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index 6ec1c03ba..000000000 --- a/.github/release.yml +++ /dev/null @@ -1,36 +0,0 @@ -# .github/release.yml - GitHub Release Notes Configuration - -changelog: - exclude: - labels: - - dependencies - - automation - - release - - repo - - skip-changelog - - chore - - ci - - build - - testing - - test - - refactor - - documentation - - translation - authors: - - renovate[bot] - - dependabot[bot] - - github-actions[bot] - - categories: - - title: 🏗️ Features - labels: - - enhancement - - feature - - title: 🛠️ Fixes - labels: - - bug - - bugfix - - fix - - title: 📝 Other Changes - labels: - - '*' diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index 1faa1a4ad..000000000 --- a/.github/renovate.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - ":dependencyDashboard", - ":semanticCommitTypeAll(chore)", - ":ignoreModulesAndTests", - "group:recommended", - "replacements:all", - "workarounds:all" - ], - "commitMessageTopic": "{{depName}}", - "labels": [ - "dependencies" - ], - "git-submodules": { - "enabled": true - }, - "bundler": { - "enabled": true - }, - "packageRules": [ - { - "description": "Automerge non-major updates for stable versions", - "matchUpdateTypes": [ - "minor", - "patch", - "pin", - "digest" - ], - "matchCurrentVersion": "!/^0/", - "automerge": true - }, - { - "description": "Automerge patch updates for unstable (0.x) versions", - "matchUpdateTypes": [ - "patch", - "pin", - "digest" - ], - "matchCurrentVersion": "/^0/", - "automerge": true - }, - { - "description": "Automerge pins and digests regardless of version", - "matchUpdateTypes": [ - "pin", - "digest" - ], - "automerge": true - }, - { - "description": "Meshtastic Protobufs changelog link", - "matchPackageNames": [ - "https://github.com/meshtastic/protobufs.git" - ], - "changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}", - "automerge": true - }, - { - "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)", - "groupName": "compose-multiplatform", - "matchPackageNames": [ - "/^org\\.jetbrains\\.compose/", - "androidx.compose.runtime:runtime-tracing", - "androidx.compose.ui:ui-test-manifest" - ] - }, - { - "description": "Restrict sensitive infrastructure to manual minor updates", - "matchUpdateTypes": [ - "minor" - ], - "matchPackageNames": [ - "/^org\\.jetbrains\\.kotlin/", - "/^org\\.jetbrains\\.kotlinx/", - "/^org\\.jetbrains\\.compose/", - "/^com\\.google\\.dagger/", - "/^androidx\\.hilt/", - "/^com\\.google\\.protobuf/", - "/^androidx\\.lifecycle/", - "/^androidx\\.navigation/", - "/^androidx\\.datastore/", - "/^androidx\\.compose\\.material3\\.adaptive/", - "/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/" - ], - "automerge": false - }, - { - "description": "Disable automerge for major updates (safety net)", - "matchUpdateTypes": [ - "major" - ], - "automerge": false - } - ] -} diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..f6ffce499 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,152 @@ +name: Android CI + +on: + push: + branches: [ master ] + paths-ignore: + - "**.md" + - ".idea/**" + - ".gitignore" + - ".gitmodules" + + pull_request: + branches: [ master ] + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Check lint + run: ./gradlew lintFdroidDebug lintGoogleDebug + + - name: Build debug artifacts + run: ./gradlew assembleDebug + + - name: Run local tests + run: ./gradlew testFdroidDebug testGoogleDebug + + - name: Upload debug artifact + uses: actions/upload-artifact@v4 + with: + name: fdroidDebug + path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk + retention-days: 30 + + - name: Upload build reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: build-reports + path: app/build/reports + retention-days: 30 + + detekt: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Check detekt + run: ./gradlew detekt + + - name: Upload build reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: detekt-reports + path: app/build/reports + retention-days: 30 + + androidTest: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + matrix: + api-level: [26, 34] + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + - uses: gradle/actions/setup-gradle@v4 + + - uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: echo "Generated AVD snapshot for caching." + + - uses: reactivecircus/android-emulator-runner@v2 + env: + ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 60 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-metrics -camera-back none + disable-animations: true + script: ./gradlew connectedFdroidDebugAndroidTest diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml deleted file mode 100644 index 3c6ddd61a..000000000 --- a/.github/workflows/create-or-promote-release.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: Create or Promote Release - -on: - workflow_dispatch: - inputs: - base_version: - description: 'Base version for the release (e.g., 2.3.0)' - required: true - channel: - description: 'The channel to create a release for or promote to' - required: true - type: choice - options: - - internal - - closed - - open - - production - dry_run: - description: 'If true, calculates the tag but does not push it or start the release' - required: true - type: boolean - default: false - build_desktop: - description: 'Whether to build the desktop distribution' - required: true - type: boolean - default: false - -permissions: - contents: write - pull-requests: read - id-token: write - attestations: write - -jobs: - determine-tags: - runs-on: ubuntu-24.04-arm - outputs: - tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }} - release_name: ${{ steps.calculate_tags.outputs.release_name }} - final_tag: ${{ steps.calculate_tags.outputs.final_tag }} - from_channel: ${{ steps.calculate_tags.outputs.from_channel }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ secrets.CROWDIN_GITHUB_TOKEN }} - - - name: Calculate tags - id: calculate_tags - run: | - BASE_VERSION="${{ inputs.base_version }}" - CHANNEL="${{ inputs.channel }}" - - if [[ "$CHANNEL" == "internal" ]]; then - # This is a new build, create a new internal tag - LATEST_TAG=$(git tag --list "v${BASE_VERSION}-internal.*" --sort=-v:refname | head -n 1) - - if [ -z "$LATEST_TAG" ]; then - INCREMENT=1 - else - INCREMENT=$(echo "$LATEST_TAG" | sed -n "s/.*-internal\.\([0-9]*\)/\1/p" | awk '{print $1+1}') - fi - - NEW_TAG="v${BASE_VERSION}-internal.${INCREMENT}" - echo "Calculated new tag: $NEW_TAG" - echo "tag_to_process=$NEW_TAG" >> $GITHUB_OUTPUT - echo "release_name=$NEW_TAG" >> $GITHUB_OUTPUT - echo "final_tag=$NEW_TAG" >> $GITHUB_OUTPUT - else - # This is a promotion, find the latest tag from the previous channel to promote - FROM_CHANNEL="internal" - if [[ "$CHANNEL" == "open" ]]; then - FROM_CHANNEL="closed" - elif [[ "$CHANNEL" == "production" ]]; then - FROM_CHANNEL="open" - fi - - LATEST_TAG_TO_PROMOTE=$(git tag --list "v${BASE_VERSION}-${FROM_CHANNEL}.*" --sort=-v:refname | head -n 1) - - if [ -z "$LATEST_TAG_TO_PROMOTE" ]; then - echo "::error::No ${FROM_CHANNEL} release found for base version ${BASE_VERSION} to promote." - exit 1 - fi - - echo "Found latest ${FROM_CHANNEL} tag to promote: $LATEST_TAG_TO_PROMOTE" - - # Calculate the increment for the TARGET channel - if [[ "$CHANNEL" != "production" ]]; then - LATEST_CHANNEL_TAG=$(git tag --list "v${BASE_VERSION}-${CHANNEL}.*" --sort=-v:refname | head -n 1) - - if [ -z "$LATEST_CHANNEL_TAG" ]; then - INCREMENT=1 - else - INCREMENT=$(echo "$LATEST_CHANNEL_TAG" | sed -n "s/.*-${CHANNEL}\.\([0-9]*\)/\1/p" | awk '{print $1+1}') - fi - - NEW_TAG="v${BASE_VERSION}-${CHANNEL}.${INCREMENT}" - else - # Production is special, it has no increment - NEW_TAG="v${BASE_VERSION}" - fi - - echo "New release name will be: $NEW_TAG" - echo "Final tag will be: $NEW_TAG" - echo "from_channel=${FROM_CHANNEL}" >> $GITHUB_OUTPUT - echo "tag_to_process=${LATEST_TAG_TO_PROMOTE}" >> $GITHUB_OUTPUT - echo "release_name=${NEW_TAG}" >> $GITHUB_OUTPUT - echo "final_tag=${NEW_TAG}" >> $GITHUB_OUTPUT - fi - shell: bash - - - name: Create and Push Release Tag - if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} - env: - FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }} - run: | - echo "Tagging and pushing release: $FINAL_TAG" - git tag "$FINAL_TAG" - git push origin "$FINAL_TAG" - shell: bash - - call-release-workflow: - if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} - needs: determine-tags - uses: ./.github/workflows/release.yml - with: - tag_name: ${{ needs.determine-tags.outputs.final_tag }} - channel: ${{ inputs.channel }} - base_version: ${{ inputs.base_version }} - build_desktop: ${{ inputs.build_desktop }} - secrets: inherit - - call-promote-workflow: - if: ${{ !inputs.dry_run && inputs.channel != 'internal' }} - needs: determine-tags - uses: ./.github/workflows/promote.yml - with: - tag_name: ${{ needs.determine-tags.outputs.tag_to_process }} - release_name: ${{ needs.determine-tags.outputs.release_name }} - final_tag: ${{ needs.determine-tags.outputs.final_tag }} - channel: ${{ inputs.channel }} - base_version: ${{ inputs.base_version }} - from_channel: ${{ needs.determine-tags.outputs.from_channel }} - secrets: inherit - - cleanup-on-failure: - needs: [determine-tags, call-release-workflow] - if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }} - runs-on: ubuntu-24.04-arm - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - name: Delete Failed or Cancelled Tag - env: - FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }} - run: | - if [ -n "$FINAL_TAG" ]; then - echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..." - git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted." - else - echo "No tag was created to delete." - fi diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml deleted file mode 100644 index 10535d723..000000000 --- a/.github/workflows/dependency-submission.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Dependency Submission - -on: - push: - branches: [ 'main' ] - workflow_dispatch: - -permissions: - contents: write - -jobs: - dependency-submission: - runs-on: ubuntu-24.04 - if: github.repository == 'meshtastic/Meshtastic-Android' - - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - token: ${{ github.token }} - - - name: Generate and submit dependency graph - uses: gradle/actions/dependency-submission@v6 - with: - build-scan-publish: true - build-scan-terms-of-use-url: "https://gradle.com/terms-of-service" - build-scan-terms-of-use-agree: "yes" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index f7c8151c7..000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,83 +0,0 @@ -# This workflow builds and deploys the Dokka documentation to GitHub Pages. - -name: Deploy Documentation - -on: - push: - branches: - - main - paths: - # Only rebuild docs when source code changes (Dokka generates from KDoc) - - 'app/src/**' - - 'core/**/src/**' - - 'feature/**/src/**' - - 'desktop/src/**' - - 'build-logic/**' - - 'build.gradle.kts' - - 'settings.gradle.kts' - - '.github/workflows/docs.yml' - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - inputs: - ref: - description: 'The branch, tag or SHA to checkout' - required: false - type: string - - # Allow this workflow to be called from other workflows - workflow_call: - inputs: - ref: - description: 'The branch, tag or SHA to checkout' - required: false - type: string - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment; cancel queued runs since only the latest -# main state matters for documentation. -concurrency: - group: "pages" - cancel-in-progress: true - -jobs: - build-docs: - if: github.repository == 'meshtastic/Meshtastic-Android' - runs-on: ubuntu-24.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: 'recursive' - ref: ${{ inputs.ref || '' }} - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - - name: Build Dokka HTML documentation - run: ./gradlew dokkaGeneratePublicationHtml - - - name: Upload artifact - uses: actions/upload-pages-artifact@v5 - with: - path: build/dokka/html - - deploy: - if: github.repository == 'meshtastic/Meshtastic-Android' - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-24.04-arm - needs: build-docs - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml deleted file mode 100644 index eaf3f54d3..000000000 --- a/.github/workflows/main-check.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Main CI (Verify & Build) - -on: - push: - branches: [ main ] - paths-ignore: - - '**/*.md' - - 'docs/**' - -permissions: - contents: read - -concurrency: - group: main-${{ github.ref }} - cancel-in-progress: true - -jobs: - validate-and-build: - if: github.repository == 'meshtastic/Meshtastic-Android' - uses: ./.github/workflows/reusable-check.yml - with: - run_lint: true - run_unit_tests: false - run_desktop_builds: false - upload_artifacts: true - secrets: inherit diff --git a/.github/workflows/main-push-changelog.yml b/.github/workflows/main-push-changelog.yml deleted file mode 100644 index da161e44e..000000000 --- a/.github/workflows/main-push-changelog.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Main Push Changelog - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: read - -concurrency: - group: main-push-${{ github.ref }} - cancel-in-progress: true - -jobs: - main-push-changelog: - name: Generate main push changelog - runs-on: ubuntu-24.04-arm - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Determine last tag - id: last_prod_tag - run: | - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - echo "Found last tag: $TAG" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - - name: Generate changelog from last tag to current - if: steps.last_prod_tag.outputs.tag != '' - uses: mikepenz/release-changelog-builder-action@v6 - id: changelog - with: - configuration: .github/release.yml - fromTag: ${{ steps.last_prod_tag.outputs.tag }} - toTag: ${{ github.sha }} - outputFile: main-push-changelog.md - fetchViaCommits: true - fetchReviewers: false - fetchReleaseInformation: false - fetchReviews: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload changelog artifact - if: steps.last_prod_tag.outputs.tag != '' - uses: actions/upload-artifact@v7 - with: - name: main-push-changelog - path: main-push-changelog.md - - - name: Print main push summary - env: - LAST_TAG: ${{ steps.last_prod_tag.outputs.tag }} - run: | - echo "Pushed to main" - echo "SHA: $GITHUB_SHA" - echo "Actor: $GITHUB_ACTOR" - echo "Ref: $GITHUB_REF" - echo "" - if [ "$LAST_TAG" != "" ]; then - echo "Changelog since last tag ($LAST_TAG)": - echo "----------------------------------------" - cat main-push-changelog.md - else - echo "No tag found. Skipping changelog generation." - fi diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml deleted file mode 100644 index 44d31183d..000000000 --- a/.github/workflows/merge-queue.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Android CI (Merge Queue) - -on: - merge_group: - types: [checks_requested] - -permissions: - contents: read - -concurrency: - group: build-mq-${{ github.ref }} - cancel-in-progress: true - -jobs: - android-check: - if: github.repository == 'meshtastic/Meshtastic-Android' - uses: ./.github/workflows/reusable-check.yml - with: - run_lint: true - run_unit_tests: true - upload_artifacts: false - secrets: inherit - - check-workflow-status: - name: Check Workflow Status - runs-on: ubuntu-24.04-arm - permissions: {} - needs: - - android-check - if: always() - steps: - - name: Check Workflow Status - run: | - if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then - echo "::error::Android Check failed" - exit 1 - fi - echo "All jobs passed successfully" diff --git a/.github/workflows/models_issue_triage.yml b/.github/workflows/models_issue_triage.yml deleted file mode 100644 index a02fb8ed8..000000000 --- a/.github/workflows/models_issue_triage.yml +++ /dev/null @@ -1,204 +0,0 @@ -name: Issue Triage (Models) - -on: - issues: - types: [opened] - -permissions: - issues: write - models: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }} - cancel-in-progress: true - -jobs: - triage: - if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }} - runs-on: ubuntu-24.04-arm - steps: - # ───────────────────────────────────────────────────────────────────────── - # Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam - # ───────────────────────────────────────────────────────────────────────── - - name: Detect spam or low-quality content - uses: actions/ai-inference@v2 - id: quality - continue-on-error: true - with: - max-tokens: 20 - prompt: | - Is this GitHub issue spam, AI-generated slop, or low quality? - - Title: ${{ github.event.issue.title }} - Body: ${{ github.event.issue.body }} - - Respond with exactly one of: spam, ai-generated, needs-review, ok - system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop. - model: openai/gpt-4o-mini - - - name: Apply quality label if needed - if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' - uses: actions/github-script@v9 - env: - QUALITY_LABEL: ${{ steps.quality.outputs.response }} - with: - script: | - const label = (process.env.QUALITY_LABEL || '').trim().toLowerCase(); - const labelMeta = { - 'spam': { color: 'd73a4a', description: 'Possible spam' }, - 'ai-generated': { color: 'fbca04', description: 'Possible AI-generated low-quality content' }, - 'needs-review': { color: 'f9d0c4', description: 'Needs human review' }, - }; - const meta = labelMeta[label]; - if (!meta) return; - - // Ensure label exists - try { - await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description }); - } - - // Apply label - await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, labels: [label] }); - - // Set output to skip remaining steps - core.setOutput('is_spam', 'true'); - - # ───────────────────────────────────────────────────────────────────────── - # Step 2: Duplicate detection - only if not spam - # ───────────────────────────────────────────────────────────────────────── - - name: Detect duplicate issues - if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '' - uses: pelikhan/action-genai-issue-dedup@bdb3b5d9451c1090ffcdf123d7447a5e7c7a2528 # v0.0.19 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - # ───────────────────────────────────────────────────────────────────────── - # Step 3: Completeness check + auto-labeling (combined into one AI call) - # ───────────────────────────────────────────────────────────────────────── - - name: Determine if completeness check should be skipped - if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '' - uses: actions/github-script@v9 - id: check-skip - with: - script: | - const title = (context.payload.issue.title || '').toLowerCase(); - const labels = (context.payload.issue.labels || []).map(label => label.name); - const hasFeatureRequest = title.includes('feature request'); - const hasEnhancement = labels.includes('enhancement'); - const shouldSkip = hasFeatureRequest && hasEnhancement; - core.setOutput('should_skip', shouldSkip ? 'true' : 'false'); - - - name: Analyze issue completeness and determine labels - if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' - uses: actions/ai-inference@v2 - id: analysis - continue-on-error: true - with: - prompt: | - Analyze this GitHub issue for the Meshtastic Android app and determine if it needs labels. - - If this looks like a bug in the Android app (crash, ANR, UI glitch, connection failure, Bluetooth issues, notification problems, map issues), request app logs and explain how to get them: - - Android app debug logs: - - Open the Meshtastic app, go to Settings > Debug > Save Logs - - Reproduce the problem, then share/attach the exported log file - - Android logcat (if app logs are insufficient): - - Connect phone via USB with USB debugging enabled - - Run: adb logcat -s Meshtastic:* *:E - - Reproduce the problem, then copy/paste the relevant output - - Also request key context if missing: Android version, phone model, app version, Meshtastic device model, firmware version, connection type (BLE/USB/TCP), steps to reproduce, expected vs actual. - - Respond ONLY with JSON: - { - "complete": true|false, - "comment": "Your helpful comment requesting missing info, or empty string if complete", - "label": "needs-logs" | "needs-info" | "none" - } - - Use "needs-logs" if this is an app bug AND no logs are attached. - Use "needs-info" if basic info like firmware version or steps to reproduce are missing. - Use "none" if the issue is complete or is a feature request. - - Title: ${{ github.event.issue.title }} - Body: ${{ github.event.issue.body }} - system-prompt: You are a helpful assistant that triages GitHub issues. Be conservative with labels. - model: openai/gpt-4o-mini - - - name: Process analysis result - if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != '' - uses: actions/github-script@v9 - id: process - env: - AI_RESPONSE: ${{ steps.analysis.outputs.response }} - with: - script: | - const raw = (process.env.AI_RESPONSE || '').trim(); - - let complete = false; - let comment = ''; - let label = 'none'; - - try { - const parsed = JSON.parse(raw); - complete = !!parsed.complete; - comment = (parsed.comment ?? '').toString().trim(); - label = (parsed.label ?? 'none').toString().trim().toLowerCase(); - } catch { - // If JSON parse fails, treat as incomplete with raw response as comment - complete = false; - comment = raw; - label = 'none'; - } - - // Validate label - const allowedLabels = new Set(['needs-logs', 'needs-info', 'none']); - if (!allowedLabels.has(label)) label = 'none'; - - core.setOutput('should_comment', (!complete && comment.length > 0) ? 'true' : 'false'); - core.setOutput('comment_body', comment); - core.setOutput('label', label); - - - name: Apply triage label - if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none' - uses: actions/github-script@v9 - env: - LABEL_NAME: ${{ steps.process.outputs.label }} - with: - script: | - const label = process.env.LABEL_NAME; - const labelMeta = { - 'needs-logs': { color: 'cfd3d7', description: 'Device logs requested for triage' }, - 'needs-info': { color: 'f9d0c4', description: 'More information requested for triage' }, - }; - const meta = labelMeta[label]; - if (!meta) return; - - // Ensure label exists - try { - await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description }); - } - - // Apply label - await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, labels: [label] }); - - - name: Comment on issue - if: steps.process.outputs.should_comment == 'true' - uses: actions/github-script@v9 - env: - COMMENT_BODY: ${{ steps.process.outputs.comment_body }} - with: - script: | - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - body: process.env.COMMENT_BODY - }); diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml deleted file mode 100644 index c2a1aaf25..000000000 --- a/.github/workflows/models_pr_triage.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: PR Triage (Models) - -on: - pull_request_target: - types: [opened] - -permissions: - pull-requests: write - issues: write - models: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - triage: - if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }} - runs-on: ubuntu-24.04-arm - steps: - # ───────────────────────────────────────────────────────────────────────── - # Step 1: Check if PR already has automation/type labels (skip if so) - # ───────────────────────────────────────────────────────────────────────── - - name: Check existing labels - uses: actions/github-script@v9 - id: check-labels - with: - script: | - const skipLabels = new Set(['automation', 'release']); - const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']); - const prLabels = context.payload.pull_request.labels.map(l => l.name); - - const shouldSkipAll = prLabels.some(l => skipLabels.has(l)); - const hasTypeLabel = prLabels.some(l => typeLabels.has(l)); - - core.setOutput('skip_all', shouldSkipAll ? 'true' : 'false'); - core.setOutput('has_type_label', hasTypeLabel ? 'true' : 'false'); - - # ───────────────────────────────────────────────────────────────────────── - # Step 2: Quality check (spam/AI-slop detection) - # ───────────────────────────────────────────────────────────────────────── - - name: Detect spam or low-quality content - if: steps.check-labels.outputs.skip_all != 'true' - uses: actions/ai-inference@v2 - id: quality - continue-on-error: true - env: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} - with: - max-tokens: 20 - prompt: | - Is this GitHub pull request spam, AI-generated slop, or low quality? - - Title: ${{ env.PR_TITLE }} - Body: ${{ env.PR_BODY }} - - Respond with exactly one of: spam, ai-generated, needs-review, ok - system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop. - model: openai/gpt-4o-mini - - - name: Apply quality label if needed - if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok' - uses: actions/github-script@v9 - id: quality-label - env: - QUALITY_LABEL: ${{ steps.quality.outputs.response }} - with: - script: | - const label = (process.env.QUALITY_LABEL || '').trim().toLowerCase(); - const labelMeta = { - 'spam': { color: 'd73a4a', description: 'Possible spam' }, - 'ai-generated': { color: 'fbca04', description: 'Possible AI-generated low-quality content' }, - 'needs-review': { color: 'f9d0c4', description: 'Needs human review' }, - }; - const meta = labelMeta[label]; - if (!meta) return; - - // Ensure label exists - try { - await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description }); - } - - // Apply label - await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: [label] }); - - core.setOutput('is_spam', 'true'); - - # ───────────────────────────────────────────────────────────────────────── - # Step 3: Auto-label PR type (bugfix/enhancement/refactor) - # ───────────────────────────────────────────────────────────────────────── - - name: Classify PR for labeling - if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') - uses: actions/ai-inference@v2 - id: classify - continue-on-error: true - env: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} - with: - max-tokens: 30 - prompt: | - Classify this pull request for the Meshtastic Android app into exactly one category. - - Return exactly one of: bugfix, enhancement, refactor - - Use bugfix if it fixes a bug, crash, or incorrect behavior. - Use enhancement if it adds a new feature, improves performance, or adds new functionality. - Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture. - - Title: ${{ env.PR_TITLE }} - Body: ${{ env.PR_BODY }} - system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label. - model: openai/gpt-4o-mini - - - name: Apply type label - if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != '' - uses: actions/github-script@v9 - env: - TYPE_LABEL: ${{ steps.classify.outputs.response }} - with: - script: | - const label = (process.env.TYPE_LABEL || '').trim().toLowerCase(); - const labelMeta = { - 'bugfix': { color: 'd73a4a', description: 'Bug fix' }, - 'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' }, - 'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' }, - }; - const meta = labelMeta[label]; - if (!meta) return; - - // Ensure label exists - try { - await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label }); - } catch (e) { - if (e.status !== 404) throw e; - await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description }); - } - - // Apply label - await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: [label] }); diff --git a/.github/workflows/moderate.yml b/.github/workflows/moderate.yml deleted file mode 100644 index 4b8f94bfa..000000000 --- a/.github/workflows/moderate.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: AI Moderator -on: - issues: - types: [opened] - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - -jobs: - spam-detection: - if: github.repository == 'meshtastic/Meshtastic-Android' - runs-on: ubuntu-24.04-arm - permissions: - issues: write - pull-requests: write - models: read - contents: read - steps: - - uses: actions/checkout@v6 - - uses: github/ai-moderator@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - spam-label: 'spam' - ai-label: 'ai-generated' - minimize-detected-comments: true - # Built-in prompt configuration (all enabled by default) - enable-spam-detection: true - enable-link-spam-detection: true - enable-ai-detection: true - # custom-prompt-path: '.github/prompts/my-custom.prompt.yml' # Optional diff --git a/.github/workflows/post-release-cleanup.yml b/.github/workflows/post-release-cleanup.yml deleted file mode 100644 index d62c36ed9..000000000 --- a/.github/workflows/post-release-cleanup.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Post-Release Cleanup - -on: - workflow_dispatch: - inputs: - base_version: - description: 'The base version to clean up (e.g., 2.3.0)' - required: true - type: string - confirm_deletion: - description: 'WARNING: This is a destructive action. Set to true to perform deletion. Defaults to a dry run.' - required: true - type: boolean - default: false - -permissions: - contents: write - -jobs: - cleanup_prereleases: - runs-on: ubuntu-24.04-arm - environment: Release - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Cleanup pre-releases and their tags - id: cleanup_releases - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BASE_VERSION="${{ github.event.inputs.base_version }}" - TAG_PREFIX="v${BASE_VERSION}-" - echo "Searching for pre-releases with tag prefix '$TAG_PREFIX'." - RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease --limit 100 | jq -r --arg prefix "$TAG_PREFIX" '.[] | select(.isPrerelease == true and .tagName != null and (.tagName | startswith($prefix))) | .tagName') - - if [ -z "$RELEASES_TO_DELETE" ]; then - echo "No pre-releases found for base version $BASE_VERSION." - else - if [[ "${{ github.event.inputs.confirm_deletion }}" == "true" ]]; then - echo "!!! DELETING RELEASES AND TAGS !!!" - echo "The following pre-releases and their tags will be deleted:" - echo "$RELEASES_TO_DELETE" - echo "$RELEASES_TO_DELETE" | xargs -n 1 gh release delete --cleanup-tag --yes - else - echo "DRY RUN: The following pre-releases and their tags would be deleted:" - echo "$RELEASES_TO_DELETE" - fi - fi - - - name: Cleanup dangling pre-release tags - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BASE_VERSION="${{ github.event.inputs.base_version }}" - TAG_PREFIX="v${BASE_VERSION}-" - echo "Searching for any remaining remote pre-release tags with prefix '$TAG_PREFIX'." - - # This finds all remote tags matching the pattern. Some may have been deleted in the previous step. - TAGS_TO_DELETE=$(git ls-remote --tags origin "refs/tags/${TAG_PREFIX}*" | awk '{print $2}' | sed 's|refs/tags/||') - - if [ -z "$TAGS_TO_DELETE" ]; then - echo "No dangling pre-release tags found." - else - if [[ "${{ github.event.inputs.confirm_deletion }}" == "true" ]]; then - echo "!!! DELETING DANGLING TAGS !!!" - echo "The following pre-release tags will be deleted:" - # We pipe to xargs which will run the command for each tag. - # If a tag was already deleted by the previous 'release delete' step, this will fail for that tag. - # We add '|| true' to ignore any errors and ensure the workflow doesn't fail. - echo "$TAGS_TO_DELETE" | xargs -n 1 -I {} sh -c 'git push --delete origin {} || true' - else - echo "DRY RUN: The following dangling pre-release tags would be deleted:" - echo "$TAGS_TO_DELETE" - fi - fi diff --git a/.github/workflows/pr_enforce_labels.yml b/.github/workflows/pr_enforce_labels.yml deleted file mode 100644 index fa68a597b..000000000 --- a/.github/workflows/pr_enforce_labels.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Check PR Labels - -on: - pull_request: - types: [edited, labeled] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - pull-requests: read - contents: read - -jobs: - check-label: - # Skip bot PRs — they already have labels from the workflows/bots that create them - if: >- - github.event.pull_request.user.login != 'renovate[bot]' && - github.event.pull_request.user.login != 'github-actions[bot]' && - github.event.pull_request.user.login != 'dependabot[bot]' && - github.event.pull_request.head.ref != 'scheduled-updates' && - github.event.pull_request.head.ref != 'l10n_main' - runs-on: ubuntu-24.04-arm - steps: - - name: Check for PR labels - uses: actions/github-script@v9 - with: - script: | - // Extract labels from the payload directly to avoid extra API calls - const latestLabels = context.payload.pull_request.labels.map(label => label.name); - const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor']; - console.log('Labels from payload:', latestLabels); - const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label)); - if (!hasRequiredLabel) { - core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`); - } diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml deleted file mode 100644 index df16866f3..000000000 --- a/.github/workflows/promote.yml +++ /dev/null @@ -1,190 +0,0 @@ -name: Promote Release - -on: - workflow_call: - inputs: - base_version: - description: 'The base version for the release (e.g., 2.3.0)' - required: true - type: string - tag_name: - description: 'The tag that triggered the release' - required: true - type: string - release_name: - description: 'The desired name for the GitHub release' - required: true - type: string - final_tag: - description: 'The final tag for the release' - required: true - type: string - commit_sha: - description: 'The commit SHA to tag' - required: false - type: string - channel: - description: 'The channel to promote to' - required: true - type: string - from_channel: - description: 'The channel to promote from' - required: true - type: string - secrets: - GSERVICES: - required: true - KEYSTORE: - required: true - KEYSTORE_FILENAME: - required: true - KEYSTORE_PROPERTIES: - required: true - DATADOG_APPLICATION_ID: - required: true - DATADOG_CLIENT_TOKEN: - required: true - GOOGLE_MAPS_API_KEY: - required: true - GOOGLE_PLAY_JSON_KEY: - required: true - GRADLE_ENCRYPTION_KEY: - required: true - DISCORD_WEBHOOK_ANDROID: - required: false - -concurrency: - group: ${{ github.workflow }}-${{ inputs.tag_name }} - cancel-in-progress: true - -permissions: - contents: write - pull-requests: read - id-token: write - attestations: write - -jobs: - prepare-build-info: - runs-on: ubuntu-24.04-arm - outputs: - APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }} - APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.commit_sha || inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' - - - name: Prep APP_VERSION_NAME - id: prep_version - env: - INPUT_TAG_NAME: ${{ inputs.tag_name }} - run: | - VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//') - echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT - echo "Parsed Version: $VERSION_NAME" - - - name: Extract VERSION_CODE_OFFSET from config.properties - id: get_version_code_offset - run: | - OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2) - echo "VERSION_CODE_OFFSET=$OFFSET" >> $GITHUB_OUTPUT - - - name: Calculate Version Code from Git Commit Count - id: calculate_version_code - run: | - COMMIT_COUNT=$(git rev-list --count HEAD) - OFFSET=${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }} - VERSION_CODE=$((COMMIT_COUNT + OFFSET)) - echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT - shell: bash - - promote-release: - runs-on: ubuntu-24.04-arm - environment: Release - needs: [ prepare-build-info ] - steps: - - name: Promote to next channel - uses: kevin-david/promote-play-release@v1.2.0 - with: - service-account-json-raw: ${{ secrets.GOOGLE_PLAY_JSON_KEY }} - package-name: 'com.geeksville.mesh' - from-track: ${{ inputs.from_channel == 'closed' && 'NewAlpha' || (inputs.from_channel == 'open' && 'beta' || 'internal') }} - to-track: ${{ inputs.channel == 'closed' && 'NewAlpha' || (inputs.channel == 'open' && 'beta' || 'production') }} - user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }} - - update-github-release: - runs-on: ubuntu-24.04-arm - needs: [ prepare-build-info, promote-release ] - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.commit_sha || inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' - - - name: Push Git Tag on Success - if: ${{ inputs.commit_sha != '' }} - run: | - git tag ${{ inputs.final_tag }} ${{ inputs.commit_sha }} - git push origin ${{ inputs.final_tag }} - - - name: Update GitHub Release with gh CLI - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release edit ${{ inputs.tag_name }} \ - --tag ${{ inputs.final_tag }} \ - --title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \ - --draft=false \ - --prerelease=${{ inputs.channel != 'production' }} - - - name: Notify Discord - if: ${{ inputs.channel != 'internal' }} - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ANDROID }} - VERSION: ${{ inputs.final_tag }} - CHANNEL: ${{ inputs.channel }} - run: | - if [[ -z "$DISCORD_WEBHOOK" ]]; then - echo "No Discord webhook provided. Skipping notification." - exit 0 - fi - - # Determine Track Name for Display - if [ "$CHANNEL" == "closed" ]; then TRACK="Alpha (Closed)"; fi - if [ "$CHANNEL" == "open" ]; then TRACK="Beta (Open)"; fi - if [ "$CHANNEL" == "production" ]; then TRACK="Production"; fi - - # Construct JSON Payload - PAYLOAD=$(cat <> $GITHUB_ENV - else - # Use a timestamp-based version for manual/branch builds to avoid collisions - # or use the base version + suffix - BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2) - echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV - fi - - - name: Publish to GitHub Packages - run: ./gradlew :core:api:publish :core:model:publish :core:proto:publish - env: - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml deleted file mode 100644 index d37cecf43..000000000 --- a/.github/workflows/pull-request-target.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: "Pull Request Labeler" -on: - pull_request_target: - types: [opened, synchronize] -# Do not execute arbitrary code on this workflow. -# See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - labeler: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-24.04-arm - steps: - - name: Auto-label PR - uses: actions/github-script@v9 - with: - script: | - const branch = context.payload.pull_request.head.ref; - const labels = new Set(); - - // enhancement: branch contains feat - if (/feat/i.test(branch)) labels.add('enhancement'); - - // bugfix: branch starts with fix or bug - if (/^(fix|bug)/i.test(branch)) labels.add('bugfix'); - - // refactor: branch starts with refactor - if (/^refactor/i.test(branch)) labels.add('refactor'); - - // repo: branch contains repo or ci - if (/repo|ci/i.test(branch)) { - labels.add('repo'); - } else { - // Also label 'repo' if .github files were changed (needs one API call) - try { - const files = await github.paginate( - github.rest.pulls.listFiles, - { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 }, - (res) => res.data.map(f => f.filename) - ); - if (files.some(f => f.startsWith('.github/'))) labels.add('repo'); - } catch (e) { - core.warning(`Could not list PR files (rate limited?): ${e.message}`); - } - } - - if (labels.size > 0) { - const labelArray = [...labels]; - core.info(`Applying labels: ${labelArray.join(', ')}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - labels: labelArray, - }); - } catch (e) { - core.warning(`Could not apply labels (rate limited?): ${e.message}`); - } - } else { - core.info('No labels matched for this PR.'); - } diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml deleted file mode 100644 index d450711ce..000000000 --- a/.github/workflows/pull-request.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Pull Request CI - -on: - pull_request: - branches: [ main ] - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - # 1. CHANGE DETECTION: Prevents unnecessary builds - check-changes: - if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' ) - runs-on: ubuntu-24.04-arm - outputs: - android: ${{ steps.filter.outputs.android }} - steps: - - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v4 - id: filter - with: - token: '' - filters: | - android: - # CI/workflow implementation - - '.github/workflows/**' - - '.github/actions/**' - # Product modules validated by reusable-check - - 'app/**' - - 'baselineprofile/**' - - 'desktop/**' - - 'core/**' - - 'feature/**' - # Shared build infrastructure - - 'build-logic/**' - - 'config/**' - - 'gradle/**' - # Root build entrypoints/config that can alter task graph or outputs - - 'build.gradle.kts' - - 'config.properties' - - 'compose_compiler_config.conf' - - 'gradle.properties' - - 'gradlew' - - 'gradlew.bat' - - 'settings.gradle.kts' - - 'test.gradle.kts' - - # 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots - verify-check-changes-filter: - if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' ) - runs-on: ubuntu-24.04-arm - steps: - - uses: actions/checkout@v6 - - name: Verify module roots are represented in check-changes filter - run: | - python3 - <<'PY' - import re - from pathlib import Path - - settings = Path('settings.gradle.kts').read_text() - workflow = Path('.github/workflows/pull-request.yml').read_text() - - module_roots = { - module.split(':')[0] - for module in re.findall(r'":([^"]+)"', settings) - } - - allowed_extra_roots = {'baselineprofile'} - expected_roots = module_roots | allowed_extra_roots - - filter_paths = { - path.split('/')[0] - for path in re.findall(r"-\s*'([^']+/\*\*)'", workflow) - } - - actual_module_roots = filter_paths & expected_roots - - missing = sorted(expected_roots - actual_module_roots) - unexpected = sorted(actual_module_roots - expected_roots) - - if missing or unexpected: - print('check-changes filter drift detected:') - if missing: - print(' Missing roots:', ', '.join(missing)) - if unexpected: - print(' Unexpected roots:', ', '.join(unexpected)) - raise SystemExit(1) - - print('check-changes filter is aligned with settings.gradle module roots.') - PY - - # 2. VALIDATION & BUILD: Delegate to reusable-check.yml - # We disable coverage and desktop builds for PRs to keep feedback fast - # (< 10 mins). Desktop compilation is already covered by the :desktop:test - # task in the shard-app test shard. - validate-and-build: - needs: check-changes - if: needs.check-changes.outputs.android == 'true' - uses: ./.github/workflows/reusable-check.yml - with: - run_lint: true - run_unit_tests: true - run_coverage: false - run_desktop_builds: false - upload_artifacts: true - secrets: inherit - - # 3. WORKFLOW STATUS: Ensures required checks are satisfied - check-workflow-status: - name: Check Workflow Status - runs-on: ubuntu-24.04-arm - permissions: {} - needs: [check-changes, verify-check-changes-filter, validate-and-build] - if: always() - steps: - - name: Check Workflow Status - run: | - if [[ "${{ needs.verify-check-changes-filter.result }}" == "failure" || "${{ needs.verify-check-changes-filter.result }}" == "cancelled" ]]; then - echo "::error::check-changes filter verification failed" - exit 1 - fi - - # If changes were detected but build failed, fail the status check - if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then - echo "::error::Android Check failed" - exit 1 - fi - - # If no changes were detected, this still succeeds to satisfy required status check - echo "Workflow status satisfied." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d8e40f3..35f457758 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,353 +1,106 @@ name: Make Release on: - workflow_call: - inputs: - base_version: - description: 'The base version for the release (e.g., 2.3.0)' - required: true - type: string - tag_name: - description: 'The tag that triggered the release' - required: true - type: string - commit_sha: - description: 'The commit SHA to build and tag' - required: false - type: string - channel: - description: 'The channel to create a release for or promote to' - required: true - type: string - build_desktop: - description: 'Whether to build the desktop distribution' - required: false - type: boolean - default: false - secrets: - GSERVICES: - required: true - KEYSTORE: - required: true - KEYSTORE_FILENAME: - required: true - KEYSTORE_PROPERTIES: - required: true - DATADOG_APPLICATION_ID: - required: true - DATADOG_CLIENT_TOKEN: - required: true - GOOGLE_MAPS_API_KEY: - required: true - GOOGLE_PLAY_JSON_KEY: - required: true - GRADLE_ENCRYPTION_KEY: - required: true - GRADLE_CACHE_URL: - required: false - GRADLE_CACHE_USERNAME: - required: false - GRADLE_CACHE_PASSWORD: - required: false - INTERNAL_BUILDS_HOST: - required: false - INTERNAL_BUILDS_HOST_PAT: - required: false - -concurrency: - group: ${{ github.workflow }}-${{ inputs.tag_name }} - cancel-in-progress: true - -permissions: - contents: write - pull-requests: read - id-token: write - attestations: write + workflow_dispatch: jobs: - prepare-build-info: - runs-on: ubuntu-24.04-arm - outputs: - APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }} - APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} - env: - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} + + release-build: + runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' - - name: Prep APP_VERSION_NAME - id: prep_version - env: - INPUT_TAG_NAME: ${{ inputs.tag_name }} - run: | - VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//') - echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT - echo "Parsed Version: $VERSION_NAME" - - name: Extract VERSION_CODE_OFFSET from config.properties - id: get_version_code_offset - run: | - OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2) - echo "VERSION_CODE_OFFSET=$OFFSET" >> $GITHUB_OUTPUT + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' - - name: Calculate Version Code from Git Commit Count - id: calculate_version_code - run: | - COMMIT_COUNT=$(git rev-list --count HEAD) - OFFSET=${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }} - VERSION_CODE=$((COMMIT_COUNT + OFFSET)) - echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT - shell: bash + - name: Get `versionCode` & `versionName` + run: | + echo "versionCode=$(grep -oP 'versionCode \K\d+' ./app/build.gradle)" >> $GITHUB_ENV + echo "versionName=$(grep -oP 'versionName \"\K[^\"]+' ./app/build.gradle)" >> $GITHUB_ENV - release-google: - runs-on: ubuntu-24.04 - needs: [prepare-build-info] - environment: Release - env: - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: 'false' - - - name: Load secrets - env: + - name: Load secrets + run: | + rm ./app/google-services.json + echo $GSERVICES > ./app/google-services.json + echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME + echo "$KEYSTORE_PROPERTIES" > ./keystore.properties + echo -e "versionCode=$versionCode\nversionName=$versionName" > ./version_info.txt + env: GSERVICES: ${{ secrets.GSERVICES }} KEYSTORE: ${{ secrets.KEYSTORE }} KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }} KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} - DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} - DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} - GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} - GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }} - run: | - rm -f ./app/google-services.json - echo $GSERVICES > ./app/google-services.json - echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME - echo "$KEYSTORE_PROPERTIES" > ./keystore.properties - echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties - echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties - echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> ./secrets.properties - echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json - - name: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.4.9' - bundler-cache: true + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + # Note: we don't use caches on release builds because we don't want to accidentally not have a virgin build machine - - name: Build and Deploy Google Play to Internal Track with Fastlane - env: - VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - run: bundle exec fastlane internal + - name: Build F-Droid release + run: ./gradlew assembleFdroidRelease - - name: List outputs - run: ls -R app/build/outputs/ + - name: Enable Crashlytics + run: sed -i 's/useCrashlytics = false/useCrashlytics = true/g' ./build.gradle - - name: Upload Google AAB artifact - if: always() - uses: actions/upload-artifact@v7 - with: - name: google-aab - path: app/build/outputs/bundle/googleRelease/app-google-release.aab - retention-days: 1 + - name: Build Play Store release + run: ./gradlew bundleGoogleRelease assembleGoogleRelease - - name: Upload Google APK artifact - if: always() - uses: actions/upload-artifact@v7 - with: - name: google-apk - path: app/build/outputs/apk/google/release/*.apk - retention-days: 1 + - name: Create GitHub release + uses: actions/create-release@v1 + id: create_release + with: + draft: true + prerelease: true + release_name: Meshtastic Android ${{ env.versionName }} alpha + tag_name: ${{ env.versionName }} + body: | + Autogenerated by github action, developer should edit as required before publishing... + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Attest Google AAB provenance - if: success() - uses: actions/attest-build-provenance@v4 - with: - subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab + - name: Add F-Droid APK to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk + asset_name: fdroidRelease-${{ env.versionName }}.apk + asset_content_type: application/zip - - name: Attest Google APK provenance - if: success() - uses: actions/attest-build-provenance@v4 - with: - subject-path: app/build/outputs/apk/google/release/*.apk + - name: Add Play Store AAB to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: app/build/outputs/bundle/googleRelease/app-google-release.aab + asset_name: googleRelease-${{ env.versionName }}.aab + asset_content_type: application/zip - release-fdroid: - runs-on: ubuntu-24.04 - needs: [prepare-build-info] - environment: Release - env: - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' + - name: Add Play Store APK to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: app/build/outputs/apk/google/release/app-google-release.apk + asset_name: googleRelease-${{ env.versionName }}.apk + asset_content_type: application/zip - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: 'false' - - - name: Load secrets - env: - KEYSTORE: ${{ secrets.KEYSTORE }} - KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }} - KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} - run: | - echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME - echo "$KEYSTORE_PROPERTIES" > ./keystore.properties - - - name: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.4.9' - bundler-cache: true - - - name: Build F-Droid with Fastlane - env: - VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - run: bundle exec fastlane fdroid_build - - - name: List outputs - run: ls -R app/build/outputs/ - - - name: Upload F-Droid APK artifact - if: always() - uses: actions/upload-artifact@v7 - with: - name: fdroid-apk - path: app/build/outputs/apk/fdroid/release/*.apk - retention-days: 1 - - - name: Attest F-Droid APK provenance - if: success() - uses: actions/attest-build-provenance@v4 - with: - subject-path: app/build/outputs/apk/fdroid/release/*.apk - - release-desktop: - if: ${{ inputs.build_desktop }} - runs-on: ${{ matrix.os }} - needs: [prepare-build-info] - environment: Release - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] - env: - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.tag_name }} - fetch-depth: 0 - submodules: 'recursive' - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: 'false' - - - name: Install dependencies for AppImage - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libfuse2 - - - name: Package Native Distributions - env: - ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - APPIMAGE_EXTRACT_AND_RUN: 1 - run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon - - - name: List Desktop Binaries - if: runner.os == 'Linux' - run: ls -R desktop/build/compose/binaries/main-release - - - name: Upload Desktop Artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: desktop-${{ runner.os }}-${{ runner.arch }} - path: | - desktop/build/compose/binaries/main-release/*/*.dmg - desktop/build/compose/binaries/main-release/*/*.msi - desktop/build/compose/binaries/main-release/*/*.exe - desktop/build/compose/binaries/main-release/*/*.deb - desktop/build/compose/binaries/main-release/*/*.rpm - desktop/build/compose/binaries/main-release/*/*.AppImage - retention-days: 1 - if-no-files-found: ignore - - github-release: - if: ${{ !cancelled() && !failure() }} - runs-on: ubuntu-24.04-arm - needs: [prepare-build-info, release-google, release-fdroid, release-desktop] - env: - INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }} - permissions: - contents: write - id-token: write - attestations: write - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{ inputs.tag_name }} - - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: ./artifacts - - - name: Create or Update GitHub Release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ inputs.tag_name }} - target_commitish: ${{ inputs.commit_sha || github.sha }} - name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}) - generate_release_notes: true - files: ./artifacts/**/* - draft: true - prerelease: true - - - name: Create or Update internal GitHub Release - continue-on-error: true - if: ${{ env.INTERNAL_BUILDS_HOST != '' }} - uses: softprops/action-gh-release@v3 - with: - repository: ${{ secrets.INTERNAL_BUILDS_HOST }} - token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }} - tag_name: ${{ inputs.tag_name }} - name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}) - generate_release_notes: false - files: ./artifacts/**/* - draft: false - prerelease: true \ No newline at end of file + # https://github.com/f-droid/fdroiddata/blob/master/metadata/com.geeksville.mesh.yml#L34 + - name: Add `version_info.txt` to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: version_info.txt + asset_name: version_info.txt + asset_content_type: text/plain diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml deleted file mode 100644 index 632bf1ea4..000000000 --- a/.github/workflows/reusable-check.yml +++ /dev/null @@ -1,315 +0,0 @@ -name: Reusable Android Check - -on: - workflow_call: - inputs: - run_lint: - type: boolean - default: true - run_unit_tests: - type: boolean - default: true - run_coverage: - type: boolean - default: true - run_desktop_builds: - type: boolean - default: true - upload_artifacts: - type: boolean - default: true - secrets: - GRADLE_ENCRYPTION_KEY: - required: false - CODECOV_TOKEN: - required: false - DATADOG_APPLICATION_ID: - required: false - DATADOG_CLIENT_TOKEN: - required: false - GOOGLE_MAPS_API_KEY: - required: false - GRADLE_CACHE_URL: - required: false - GRADLE_CACHE_USERNAME: - required: false - GRADLE_CACHE_PASSWORD: - required: false - -env: - DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }} - DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }} - MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} - GITHUB_TOKEN: ${{ github.token }} - GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} - GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} - GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} - # Fallback VERSION_CODE for the lint-check job itself (which computes the real - # value from git). Downstream jobs override this with the git-derived value. - VERSION_CODE: ${{ github.run_number }} - -jobs: - # ── Lint & Static Analysis ────────────────────────────────────────── - lint-check: - runs-on: ubuntu-24.04 - permissions: - contents: read - timeout-minutes: 30 - outputs: - cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }} - version_code: ${{ steps.version_code.outputs.version_code }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - filter: 'blob:none' - submodules: true - - - name: Determine cache read-only setting - id: cache_config - shell: bash - run: | - if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.event_name }}" == "merge_group" ]] || [[ "${{ github.ref }}" == gh-readonly-queue/* ]]; then - echo "cache_read_only=false" >> "$GITHUB_OUTPUT" - else - echo "cache_read_only=true" >> "$GITHUB_OUTPUT" - fi - - - name: Calculate version code from git commit count - id: version_code - shell: bash - run: | - COMMIT_COUNT=$(git rev-list --count HEAD) - OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2 || echo 0) - VERSION_CODE=$((COMMIT_COUNT + OFFSET)) - echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }} - - - name: Lint, Analysis & KMP Smoke Compile - if: inputs.run_lint == true - run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan - - - name: KMP Smoke Compile (lint skipped) - if: inputs.run_lint == false - run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan - - # ── Sharded Unit Tests ────────────────────────────────────────────── - # Tests are split into 3 shards that run in parallel: - # shard-core: core:* KMP module tests (allTests) - # shard-feature: feature:* KMP module tests (allTests) - # shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.) - test-shards: - runs-on: ubuntu-24.04 - permissions: - contents: read - timeout-minutes: 45 - needs: lint-check - if: inputs.run_unit_tests == true - env: - VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - strategy: - fail-fast: false - matrix: - shard: - - name: shard-core - tasks: >- - :core:ble:allTests - :core:common:allTests - :core:data:allTests - :core:database:allTests - :core:domain:allTests - :core:model:allTests - :core:navigation:allTests - :core:network:allTests - :core:prefs:allTests - :core:repository:allTests - :core:service:allTests - :core:takserver:allTests - :core:testing:allTests - :core:ui:allTests - kover: >- - :core:ble:koverXmlReport - :core:common:koverXmlReport - :core:data:koverXmlReport - :core:database:koverXmlReport - :core:domain:koverXmlReport - :core:model:koverXmlReport - :core:navigation:koverXmlReport - :core:network:koverXmlReport - :core:prefs:koverXmlReport - :core:repository:koverXmlReport - :core:service:koverXmlReport - :core:takserver:koverXmlReport - :core:testing:koverXmlReport - :core:ui:koverXmlReport - - name: shard-feature - tasks: >- - :feature:connections:allTests - :feature:firmware:allTests - :feature:intro:allTests - :feature:map:allTests - :feature:messaging:allTests - :feature:node:allTests - :feature:settings:allTests - kover: >- - :feature:connections:koverXmlReport - :feature:firmware:koverXmlReport - :feature:intro:koverXmlReport - :feature:map:koverXmlReport - :feature:messaging:koverXmlReport - :feature:node:koverXmlReport - :feature:settings:koverXmlReport - - name: shard-app - tasks: >- - :app:testFdroidDebugUnitTest - :app:testGoogleDebugUnitTest - :desktop:test - :core:barcode:testFdroidDebugUnitTest - :core:barcode:testGoogleDebugUnitTest - kover: >- - :app:koverXmlReportFdroidDebug - :app:koverXmlReportGoogleDebug - :core:barcode:koverXmlReportFdroidDebug - :core:barcode:koverXmlReportGoogleDebug - :desktop:koverXmlReport - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - submodules: true - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - - name: Run Tests & Coverage (${{ matrix.shard.name }}) - run: | - kover_tasks="" - if [[ "${{ inputs.run_coverage }}" == "true" ]]; then - kover_tasks="${{ matrix.shard.kover }}" - fi - ./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: meshtastic/Meshtastic-Android - flags: ${{ matrix.shard.name }} - fail_ci_if_error: false - report_type: test_results - files: "**/build/test-results/**/*.xml" - - - name: Upload coverage to Codecov - if: ${{ !cancelled() && inputs.run_coverage }} - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: meshtastic/Meshtastic-Android - flags: ${{ matrix.shard.name }} - fail_ci_if_error: false - files: "**/build/reports/kover/report*.xml" - - - name: Upload shard reports - if: ${{ always() && inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: reports-${{ matrix.shard.name }} - path: | - **/build/reports - **/build/test-results - retention-days: 7 - - # ── Android Build ──────────────────────────────────────────────────── - android-check: - runs-on: ubuntu-24.04 - permissions: - contents: read - timeout-minutes: 60 - needs: lint-check - env: - VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - submodules: true - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - - name: Build Android APKs - run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan - - - name: Upload debug artifact - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: app-debug-apks - path: app/build/outputs/apk/*/debug/*.apk - retention-days: 7 - - - name: Report App Size - if: always() - run: | - echo "### App Size Report" >> $GITHUB_STEP_SUMMARY - echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY - echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY - find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY - - # ── Desktop Build ─────────────────────────────────────────────────── - build-desktop: - name: Build Desktop Debug (${{ matrix.os }}) - if: inputs.run_desktop_builds == true - runs-on: ${{ matrix.os }} - permissions: - contents: read - timeout-minutes: 60 - needs: lint-check - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm] - env: - VERSION_CODE: ${{ needs.lint-check.outputs.version_code }} - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 1 - submodules: true - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }} - - - name: Build Desktop - run: ./gradlew :desktop:createDistributable -Pci=true --scan - - - name: Upload Desktop artifact - if: ${{ inputs.upload_artifacts }} - uses: actions/upload-artifact@v7 - with: - name: desktop-app-${{ runner.os }}-${{ runner.arch }} - path: desktop/build/compose/binaries/main/app/ - retention-days: 7 diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml deleted file mode 100644 index 2399d1f88..000000000 --- a/.github/workflows/scheduled-updates.yml +++ /dev/null @@ -1,145 +0,0 @@ -name: Scheduled Updates (Firmware, Hardware, Translations) - -on: - schedule: - - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost) - workflow_dispatch: # Allow manual triggering - -jobs: - update_assets: - runs-on: ubuntu-24.04 - if: github.repository == 'meshtastic/Meshtastic-Android' - permissions: - contents: write # To commit files and push branches - pull-requests: write # To create pull requests - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - token: ${{ secrets.CROWDIN_GITHUB_TOKEN }} - - - name: Update firmware releases list - run: | - firmware_file_path="app/src/main/assets/firmware_releases.json" - temp_firmware_file="/tmp/new_firmware_releases.json" - - echo "Fetching latest firmware releases..." - curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file" - - if ! jq empty "$temp_firmware_file" 2>/dev/null; then - echo "::error::Firmware API returned invalid JSON data. Skipping firmware update." - else - if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then - echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path." - cp "$temp_firmware_file" "$firmware_file_path" - else - echo "No changes detected in firmware list." - fi - fi - - - name: Update hardware list - run: | - hardware_file_path="app/src/main/assets/device_hardware.json" - temp_hardware_file="/tmp/new_device_hardware.json" - - echo "Fetching latest device hardware data..." - curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file" - - if ! jq empty "$temp_hardware_file" 2>/dev/null; then - echo "::error::Hardware API returned invalid JSON data. Skipping hardware update." - else - if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then - echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path." - cp "$temp_hardware_file" "$hardware_file_path" - else - echo "No changes detected in hardware list." - fi - fi - - - name: Sync with Crowdin - uses: crowdin/github-action@v2 - with: - base_url: 'https://meshtastic.crowdin.com/api/v2' - config: 'crowdin.yml' - crowdin_branch_name: 'main' - upload_sources: true - upload_sources_args: '--preserve-hierarchy' - upload_translations: false - download_translations: true - download_translations_args: '--preserve-hierarchy' - create_pull_request: false - commit_message: 'chore(l10n): New Crowdin Translations from scheduled update' - push_translations: false - push_sources: false - localization_branch_name: ${{ github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} - - - name: Fix file permissions - run: sudo chown -R $USER:$USER . - - - name: Gradle Setup - uses: ./.github/actions/gradle-setup - with: - gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache_read_only: 'false' - - - name: Update Graphs - run: ./gradlew graphUpdate - continue-on-error: true - - - name: Create Pull Request if changes occurred - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ secrets.CROWDIN_GITHUB_TOKEN }} - commit-message: | - chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) - - Automated updates for: - - Firmware releases list - - Device hardware list - - Crowdin source string uploads - - Crowdin translation downloads - - Module dependency graphs - title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)' - body: | - This PR includes automated updates from the scheduled workflow: - - - Updated `firmware_releases.json` from the Meshtastic API (if changed). - - Updated `device_hardware.json` from the Meshtastic API (if changed). - - Source strings were uploaded to Crowdin. - - Latest translations were downloaded from Crowdin (if available). - - Updated module dependency graphs in README.md files (if changed). - - Please review the changes. - branch: 'scheduled-updates' - base: 'main' - delete-branch: true - add-paths: | - app/src/main/assets/firmware_releases.json - app/src/main/assets/device_hardware.json - fastlane/metadata/android/** - **/strings.xml - **/README.md - labels: | - automation - l10n - firmware - hardware - - check-workflow-status: - name: Check Workflow Status - runs-on: ubuntu-24.04-arm - permissions: {} - needs: - - update_assets - if: always() - steps: - - name: Check Workflow Status - if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')" - run: | - echo "One of the dependent jobs failed or was cancelled. Failing the workflow." - exit 1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index f1ae45660..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Close Stale Issues -on: - schedule: - - cron: 0 6 * * * - workflow_dispatch: {} - -permissions: - issues: write - pull-requests: write - actions: write - -jobs: - stale_issues: - name: Close Stale Issues - runs-on: ubuntu-24.04-arm - if: github.repository == 'meshtastic/Meshtastic-Android' - - steps: - - name: Stale PR+Issues - uses: actions/stale@v10.2.0 - with: - days-before-stale: 30 - stale-issue-message: This issue has not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days. - exempt-issue-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies' - exempt-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies' - operations-per-run: 100 diff --git a/.gitignore b/.gitignore index 447d8a28e..04fe5cb1a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,21 +3,17 @@ !/.idea/codeStyles !/.idea/dictionaries/ !/.idea/runConfigurations/ -!/.idea/icon.svg *.iws *.iml .gradle /local.properties .DS_Store -**/build/** +/build /captures .externalNativeBuild .cxx /app/release -/buildSrc/build -**/debug/** -**/release/** # Java KeyStore certificates *.jks @@ -28,31 +24,3 @@ keystore.properties # Kotlin compiler .kotlin - -# VS code -.vscode/settings.json - -# Secrets -/secrets.properties -/fastlane/play-store-credentials.json -**/google-services.json - -# Generated library definitions -**/src/main/resources/aboutlibraries.json - -/fastlane/report.xml - -/build-logic/convention/build/* -/build-logic/build/ - -# Personal build scripts -build-and-install-android.sh -wireless-install.sh - -# Git worktrees -.worktrees/ -/firebase-debug.log.jdk/ -firebase-debug.log -.agent_plans/ -.agent_refs/ -.agent_artifacts/ diff --git a/.gitmodules b/.gitmodules index e115fe990..5b0a38e1e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ -[submodule "app proto submodule"] - path = core/proto/src/main/proto +[submodule "app/src/main/proto"] + path = app/src/main/proto url = https://github.com/meshtastic/protobufs.git +[submodule "design"] + path = design + url = https://github.com/meshtastic/design.git diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 5f6f66925..f69257a71 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,40 +1,5 @@ - - - diff --git a/.idea/icon.svg b/.idea/icon.svg deleted file mode 100644 index e6863f6a6..000000000 --- a/.idea/icon.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - -Created with Fabric.js 4.6.0 - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..8a10375bd --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/.jdk b/.jdk deleted file mode 120000 index 096e1a9e3..000000000 --- a/.jdk +++ /dev/null @@ -1 +0,0 @@ -/home/james/.jdks/ms-17.0.18 \ No newline at end of file diff --git a/.pr5167.diff b/.pr5167.diff deleted file mode 100644 index d0a809449..000000000 --- a/.pr5167.diff +++ /dev/null @@ -1,295 +0,0 @@ -diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt -new file mode 100644 -index 0000000000..2a27b96906 ---- /dev/null -+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt -@@ -0,0 +1,39 @@ -+/* -+ * Copyright (c) 2026 Meshtastic LLC -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package org.meshtastic.core.common.di -+ -+import kotlinx.coroutines.CoroutineScope -+import kotlinx.coroutines.SupervisorJob -+import org.koin.core.annotation.Single -+import org.meshtastic.core.common.util.ioDispatcher -+ -+/** -+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components. -+ * -+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled -+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not -+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread. -+ * -+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch -+ * and should be used sparingly. -+ */ -+interface ApplicationCoroutineScope : CoroutineScope -+ -+@Single(binds = [ApplicationCoroutineScope::class]) -+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope { -+ override val coroutineContext = SupervisorJob() + ioDispatcher -+} -diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -index 231c84d401..5365ab95e2 100644 ---- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect - import co.touchlab.kermit.Logger - import com.eygraber.uri.toAndroidUri - import com.eygraber.uri.toKmpUri --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.withContext - import org.jetbrains.compose.resources.StringResource - import org.jetbrains.compose.resources.getString - import org.meshtastic.core.common.gpsDisabled - import org.meshtastic.core.common.util.CommonUri -+import org.meshtastic.core.common.util.ioDispatcher - import java.net.URLEncoder - - @Composable -@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> - val context = LocalContext.current - return remember(context) { - { uri, maxChars -> -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - @Suppress("TooGenericExceptionCaught") - try { - val androidUri = uri.toAndroidUri() -diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -index 031e1fe35d..a938f92ea6 100644 ---- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt -@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util - - import androidx.compose.runtime.Composable - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.withContext - import org.jetbrains.compose.resources.StringResource - import org.meshtastic.core.common.util.CommonUri -+import org.meshtastic.core.common.util.ioDispatcher - import java.awt.Desktop - import java.awt.FileDialog - import java.awt.Frame -@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT - /** JVM — Reads text from a file URI. */ - @Composable - actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars -> -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - @Suppress("TooGenericExceptionCaught") - try { - val file = File(URI(uri.toString())) -diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -index dc1c459716..f8ff9fcac8 100644 ---- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt -@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch - import kotlinx.coroutines.withTimeoutOrNull - import org.jetbrains.compose.resources.StringResource - import org.koin.core.annotation.KoinViewModel -+import org.meshtastic.core.common.di.ApplicationCoroutineScope - import org.meshtastic.core.common.util.CommonUri - import org.meshtastic.core.common.util.safeCatching - import org.meshtastic.core.database.entity.FirmwareRelease -@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel( - private val firmwareUpdateManager: FirmwareUpdateManager, - private val usbManager: FirmwareUsbManager, - private val fileHandler: FirmwareFileHandler, -+ private val applicationScope: ApplicationCoroutineScope, - ) : ViewModel() { - - private val _state = MutableStateFlow(FirmwareUpdateState.Idle) -@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel( - - override fun onCleared() { - super.onCleared() -- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a -- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a -- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope -- // is cancelled concurrently. -- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) -- kotlinx.coroutines.GlobalScope.launch(NonCancellable) { -+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the -+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup -+ // running even if something tries to cancel it mid-flight. -+ applicationScope.launch(NonCancellable) { - tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) - } - } -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -index 4c48a1ced5..030d84effd 100644 ---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt -@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - @Test -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -index 7032ed4088..a8eddff838 100644 ---- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt -@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - @Test -diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt -new file mode 100644 -index 0000000000..3ef5c44ef4 ---- /dev/null -+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt -@@ -0,0 +1,26 @@ -+/* -+ * Copyright (c) 2026 Meshtastic LLC -+ * -+ * This program is free software: you can redistribute it and/or modify -+ * it under the terms of the GNU General Public License as published by -+ * the Free Software Foundation, either version 3 of the License, or -+ * (at your option) any later version. -+ * -+ * This program is distributed in the hope that it will be useful, -+ * but WITHOUT ANY WARRANTY; without even the implied warranty of -+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -+ * GNU General Public License for more details. -+ * -+ * You should have received a copy of the GNU General Public License -+ * along with this program. If not, see . -+ */ -+package org.meshtastic.feature.firmware -+ -+import kotlinx.coroutines.CoroutineDispatcher -+import kotlinx.coroutines.CoroutineScope -+import kotlinx.coroutines.SupervisorJob -+import org.meshtastic.core.common.di.ApplicationCoroutineScope -+ -+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) : -+ ApplicationCoroutineScope, -+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher) -diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -index acb1545bdd..23a0d03ab2 100644 ---- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt -@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest { - firmwareUpdateManager, - usbManager, - fileHandler, -+ TestApplicationCoroutineScope(testDispatcher), - ) - - // ----------------------------------------------------------------------- -diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -index c251b4d5ef..315ad1da85 100644 ---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger - import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - import org.meshtastic.core.resources.Res - import org.meshtastic.core.resources.debug_export_failed - import org.meshtastic.core.resources.debug_export_success -@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) = -- withContext(Dispatchers.IO) { -+ withContext(ioDispatcher) { - try { - if (logs.isEmpty()) { - withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } -diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -index 9afde85e5f..a28a576788 100644 ---- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt -@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable - import androidx.compose.runtime.rememberCoroutineScope - import androidx.compose.ui.platform.LocalContext - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - - @Composable - actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { -@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr - return { fileName -> exportLauncher.launch(fileName) } - } - --private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) { -+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { - try { - context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } - Logger.i { "TAK data package exported successfully to $targetUri" } -diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -index 5b63cc90a3..a9a7285593 100644 ---- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt -@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging - import androidx.compose.runtime.Composable - import androidx.compose.runtime.rememberCoroutineScope - import co.touchlab.kermit.Logger --import kotlinx.coroutines.Dispatchers - import kotlinx.coroutines.launch - import kotlinx.coroutines.withContext -+import org.meshtastic.core.common.util.ioDispatcher - import java.awt.FileDialog - import java.awt.Frame - import java.io.File -@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr - if (directory != null && file != null) { - val targetFile = File(directory, file) - val data = dataPackageProvider() -- withContext(Dispatchers.IO) { targetFile.writeBytes(data) } -+ withContext(ioDispatcher) { targetFile.writeBytes(data) } - Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } - } - } diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 7bcbb3808..000000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.4.9 diff --git a/.run/Pre-Commit [spotlessApply detekt].run.xml b/.run/Pre-Commit [spotlessApply detekt].run.xml deleted file mode 100644 index e5dd9e926..000000000 --- a/.run/Pre-Commit [spotlessApply detekt].run.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - true - true - false - - false - false - - false - false - false - false - - - \ No newline at end of file diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md deleted file mode 100644 index acab253d5..000000000 --- a/.skills/code-review/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ -# Skill: Code Review - -## Description -Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices. - -## Code Review Checklist - -When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix. - -### 1. KMP Architecture & Source Set Boundaries -- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets. -- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries: - - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex` - - `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()` - - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`) - - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`) -- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`). -- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`. -- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target. -- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities. - -### 2. UI & Compose Multiplatform (CMP) -- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine. -- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI). -- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`. -- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules. -- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp). - -### 3. Navigation & State -- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets. -- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves. -- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry` blocks to correctly tie to the backstack lifetime. - -### 4. Dependency Injection (Koin Annotations) -- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`). -- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`). - -### 5. Networking, DB & I/O -- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp. -- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths. -- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers. -- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`. -- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead. -- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions. - -### 6. Dependency Catalog Aliases -- [ ] **JetBrains vs. AndroidX:** - - In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`). - - In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`. -- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules. - -### 7. Testing -- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests. -- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`. -- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. -- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues. - -### 8. ProGuard / R8 Rules -- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned. -- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed. - -## Review Output Guidelines -1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern. -2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio"). -3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous. -4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions. diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md deleted file mode 100644 index 22fe1b489..000000000 --- a/.skills/compose-ui/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ -# Skill: Compose Multiplatform (CMP) UI - -## Description -Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive. - -## 1. UI Components & Layouts -- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets. -- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups. -- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. -- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`. -- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`. - -## 2. Strings & Resources -- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings. -- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context. -- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer). - - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`): - ```kotlin - val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5" - stringResource(Res.string.battery_percent, formatted) // uses %1$s - ``` - - **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings. - -### String Formatting Decision Tree -Choose the right tool for the job: - -| Scenario | Tool | Example | -|----------|------|---------| -| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` | -| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` | -| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` | -| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` | -| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` | -| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` | - -**Rules:** -1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats. -2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls. -3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`. -4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision. - -- **Workflow to Add a String:** - 1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`. - 2. Use the generated `org.meshtastic.core.resources.` symbol. - 3. Validate UI presentation. - -## 3. Tooling & Capabilities -- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules. -- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code. - -## 4. Compose Previews -- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables. -- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated. - -## 5. Dialog & State Patterns -- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed. - -## Reference Anchors -- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml` -- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` -- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md deleted file mode 100644 index 0277bee10..000000000 --- a/.skills/implement-feature/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ -# Skill: Implement a Feature - -## Description -A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture. - -## Workflow - -### 1. Update Dependencies & Aliases -- Check `gradle/libs.versions.toml` before adding libraries. -- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`. -- Use `compose-multiplatform-*` aliases for CMP dependencies. - -### 2. Define the State & ViewModels -- Follow MVI/UDF patterns. -- Extend shared ViewModel logic in `feature//src/commonMain/kotlin/org/meshtastic/feature//ViewModel.kt`. -- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows. -- Keep the ViewModel free of Android framework dependencies. - -### 3. Build the UI -- Use Jetpack Compose Multiplatform (CMP). -- Define strings in `core:resources` (see the `compose-ui` skill). -- Support adaptive layouts (Large/XL breakpoints). - -### 4. Wire Navigation & DI -- Define typed route objects in `core:navigation`. -- Export the navigation graph as an extension function on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.myFeatureGraph()`). -- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`. -- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell. - -### 5. Validate Platform Separation -- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin. - -### 6. Verify Locally -- Run the baseline checks (see `testing-ci` skill): - ```bash - ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests - ``` -- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds: - ```bash - ./gradlew assembleFdroidRelease :desktop:runRelease - ``` diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md deleted file mode 100644 index 46602c430..000000000 --- a/.skills/kmp-architecture/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ -# Skill: KMP Architecture & Source-Set Bridging - -## Description -Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules. - -## 1. Source-Set Boundaries -- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports. -- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings). -- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks. -- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`. - -## 2. Bridging Strategies -- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`. -- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`. - - **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors. -- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper. - -## 3. Core Libraries & Constraints -- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic. -- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops). -- **Standard Library Replacements:** - - `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`. - - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`. - - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`). -- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging. -- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL. -- **BLE:** Route through `core:ble` using **Kable**. -- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`. - -## 4. Hierarchy & Source-Set Conventions -- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model. -- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient. -- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`. - -## 5. Dependency Catalog Aliases -- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. -- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`. -- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available. - -## 6. I/O & Serialization -- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision. -- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. -- **Room Patterns:** - - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic. - - Use `LIMIT 1` on `@Query` methods that expect a single row. - - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit). - -## 7. Build-Logic Conventions -- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. - -## 8. Onboarding a New Target (Desktop/iOS) -1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.). -2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds. -3. Test using `kmpSmokeCompile` to verify cross-platform compilation. -4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations. - -## Reference Anchors -- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` -- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt` -- **Version Catalog:** `gradle/libs.versions.toml` -- **Convention Plugins:** `build-logic/convention/` diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md deleted file mode 100644 index c9d7336a6..000000000 --- a/.skills/navigation-and-di/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ -# Skill: DI and Navigation 3 Architecture - -## Description -This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase. - -## Dependency Injection (Koin) - -### Guidelines -1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature. -2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`. -3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead. -4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs. - -### Anti-Patterns -- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead. -- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values. - -### Koin Startup Pattern (K2 Compiler Plugin) -The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin()` stub, which the plugin transforms at compile time via IR: -```kotlin -// Bootstrap class — separate from @Module, references the root module graph -@KoinApplication(modules = [AppKoinModule::class]) -object AndroidKoinApp - -// In Application.onCreate() -startKoin { - androidContext(this@MeshUtilApplication) - workManagerFactory() -} -``` -- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class. -- `startKoin()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`. -- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`). -- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag. - -## Navigation 3 - -### Guidelines -1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`). -2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings. -3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack)`). -4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules. -5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`. -6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths. - -### Anti-Patterns -- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`). -- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction. -- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack` directly with `add(...)` and `removeLastOrNull()`. - -## Reference Anchors -- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt` -- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt` -- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` -- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` -- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt` diff --git a/.skills/new-branch/SKILL.md b/.skills/new-branch/SKILL.md deleted file mode 100644 index d63f3f4c2..000000000 --- a/.skills/new-branch/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ -# Skill: New Branch Bootstrap - -## Description -Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill -whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh -branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work. - -This replaces the ad-hoc prose that used to be retyped at the start of every session. - -## When to Use -- Starting any new feature, fix, chore, or refactor. -- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)). -- Reproducing a CI failure from a clean baseline. - -## Preconditions (verify before branching) -1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding. -2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at - `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream. -3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md - workspace bootstrap rules. -4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties` - (required for `google` flavor builds). - -## Standard Recipe - -```bash -# 1. Fetch latest upstream -git fetch upstream --prune --tags - -# 2. Create the branch from upstream/main (never from a local stale main) -git switch -c upstream/main - -# 3. Ensure submodules track the new base -git submodule update --init --recursive - -# 4. Sanity check -git --no-pager log -1 --oneline -``` - -## Branch Naming -Use conventional-commit style prefixes that match the PR title convention in AGENTS.md -``: - -| Prefix | Use for | -| :--- | :--- | -| `feat/` | New user-visible behavior | -| `fix/` | Bug fixes | -| `refactor/` | Code structure changes, no behavior change | -| `chore/` | Tooling, deps, CI, cleanup | -| `docs/` | Documentation only | - -Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`. - -## Rebase Variant -When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*: - -```bash -git fetch upstream --prune -gh pr checkout # checks out the PR head locally -git rebase upstream/main -git submodule update --init --recursive -# Resolve conflicts, then: -git push --force-with-lease -``` - -Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes. - -## Post-Branch Checklist -- [ ] Branch name follows conventional prefix. -- [ ] Submodules up to date. -- [ ] `local.properties` exists. -- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap). -- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing. - -## Tip: Prefer `/delegate` for Long Audits -If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since -v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider -suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR -end-to-end while the user keeps working locally. See AGENTS.md ``. diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md deleted file mode 100644 index 2224fa7ad..000000000 --- a/.skills/project-overview/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ -# Skill: Project Overview & Codebase Map - -## Description -Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android. - -- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26. -- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics) -- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`. - -## Codebase Map - -| Directory | Description | -| :--- | :--- | -| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. | -| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). | -| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). | -| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. | -| `core/model` | Domain models and common data structures. | -| `core:proto` | Protobuf definitions (Git submodule). | -| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. | -| `core:database` | Room KMP database implementation. | -| `core:datastore` | Multiplatform DataStore for preferences. | -| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). | -| `core:domain` | Pure KMP business logic and UseCases. | -| `core:data` | Core manager implementations and data orchestration. | -| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). | -| `core:di` | Common DI qualifiers and dispatchers. | -| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. | -| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. | -| `core:service` | KMP service layer; Android bindings stay in `androidMain`. | -| `core:api` | Public AIDL/API integration module for external clients. | -| `core:prefs` | KMP preferences layer built on DataStore abstractions. | -| `core:barcode` | Barcode scanning (Android-only). | -| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. | -| `core/ble/` | Bluetooth Low Energy stack using Kable. | -| `core/resources/` | Centralized string and image resources (Compose Multiplatform). | -| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. | -| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. | -| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | -| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. | - -## Namespacing -- **Standard:** Use the `org.meshtastic.*` namespace for all code. -- **Legacy:** Maintain the `com.geeksville.mesh` Application ID. - -## Environment Setup -1. **JDK 21 MUST be used** to prevent Gradle sync/build failures. -2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`: - ```properties - MAPS_API_KEY=dummy_key - datadogApplicationId=dummy_id - datadogClientToken=dummy_token - ``` - -## Workspace Bootstrap (MUST run before any build) -Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you. - -1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it: - ```bash - # Check common macOS/Linux locations in order of preference - if [ -z "$ANDROID_HOME" ]; then - for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do - if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi - done - fi - ``` - All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path. - -2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors: - ```bash - git submodule update --init - ``` - -3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails: - ```bash - [ -f local.properties ] || cp secrets.defaults.properties local.properties - ``` - -## Troubleshooting -- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts. -- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist. -- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`. diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md deleted file mode 100644 index 1c8b7b901..000000000 --- a/.skills/testing-ci/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ -# Skill: Testing and CI Verification - -## Description -Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type. - -## 1) Baseline local verification order - -Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation: - -```bash -./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests -``` - -> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues. - -> **Why `test allTests` and not just `test`:** -> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and -> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules. -> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin. -> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed. - -*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.* - -## 2) Change-type verification matrix - -- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical. -- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`. -- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`. -- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available. - - If touching any KMP module, also run `kmpSmokeCompile`. -- `worker/service/background` changes: Broad tests, targeted WorkManager checks. -- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`. - -## 3) Flavor checks - -Run these when relevant to map, provider, or flavor-specific behavior: - -```bash -./gradlew lintFdroidDebug lintGoogleDebug -./gradlew testFdroidDebug testGoogleDebug -``` - -## 4) CI Pipeline Architecture - -CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups: - -1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs. -2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`): - - `shard-core`: `allTests` for all `core:*` KMP modules. - - `shard-feature`: `allTests` for all `feature:*` KMP modules. - - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`). - Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. - Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones. -3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`). -4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`). - -### Runner Strategy (Three Tiers) -- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times. -- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility. -- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging. - -### CI Gradle Properties -`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides: -- `org.gradle.daemon=false` (single-use runners) -- `kotlin.incremental=false` (fresh checkouts) -- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon -- VFS watching disabled, workers capped at 4 -- `org.gradle.isolated-projects=true` for better parallelism -- Disables unused Android build features (`resvalues`, `shaders`) - -### CI Conventions -- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks. -- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`. -- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations. -- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level. -- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing. -- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`). -- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10). -- **`fail-fast: false`:** Test sharding does not cancel other shards on failure. -- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI. -- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`). -- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache. -- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.). -- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone. - diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c1bafdd96..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,108 +0,0 @@ -# Meshtastic Android - Unified Agent & Developer Guide - - -You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns. - - - -- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience. -- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP. -- **Core Architecture:** - - `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings. - - App root DI and graph assembly live in the `app` and `desktop` host shells. -- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work: - - `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting. - - `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions. - - `.skills/compose-ui/` - Adaptive UI, placeholders, string resources. - - `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations. - - `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties. - - `.skills/implement-feature/` - Step-by-step feature workflow. - - `.skills/code-review/` - PR validation checklist. - - `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs. -- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch. - - - -- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically: - 1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user. - 2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds. - 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails. -- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing. -- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code. -- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first). -- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing: - ``` - ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests - ``` - > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required. - > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job). - - - -- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search. -- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt. -- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11. -- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended: - - `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs) - - `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x) - - `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI) - - `https://github.com/JuulLabs/kable` (BLE) - - `https://github.com/coil-kt/coil` (Coil 3 KMP) - - `https://github.com/ktorio/ktor` (Ktor Networking) -- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing. - - - -`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here: -- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`. -- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions. -- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly. - -Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed. - - - -- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application. -- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly. -- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated. -- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code. -- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds. -- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist. -- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution. -- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them. -- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules. -- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change. - - - -These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this -section. - -- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet" - prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*, - *"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub - cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive - session on work that can run unattended. -- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"* - on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep - research across GitHub and the web with better source grounding than an ad-hoc prompt. -- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation - plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to - plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent - from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to - `.agent_plans/` (git-ignored) for multi-module refactors. -- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle - quality passes, offer `/share` to export the findings to a gist or markdown file. These - reports are valuable artifacts — don't let them die in session history. -- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts - file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration. - Avoid re-issuing the same prompt verbatim. -- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main" - or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe. - - - -- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation. -- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style. -- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters. - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index eb5cd5e5c..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# Meshtastic Android - Claude Code Guide - -@AGENTS.md - -## Claude-Specific Instructions - -- **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first. -- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task. -- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `` section. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 6843fc85d..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,4 +0,0 @@ -# Contributor Covenant Code of Conduct - -The Meshtastic Firmware project is subject to the code of conduct for the parent project, which can be found here: -https://meshtastic.org/docs/legal/conduct/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index d4fe0b740..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,94 +0,0 @@ -# Contributing to Meshtastic-Android - -Thank you for your interest in contributing to Meshtastic-Android! We welcome contributions from everyone. Please take a moment to review these guidelines to help us maintain a high-quality, collaborative project. - -## How to Contribute - -- **Fork the repository** and create your branch from `main` or the appropriate feature branch. -- **Make your changes** in a logical, atomic manner. -- **Test your changes** thoroughly before submitting a pull request. -- **Submit a pull request** (PR) with a clear description of your changes and the problem they solve. -- If you are addressing an existing issue, please reference it in your PR (e.g., `Fixes #123`). - -## Code Style - -- Follow the [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) for Kotlin code. -- Use Android Studio's default formatting settings. -- We use [spotless](https://github.com/diffplug/spotless) for automated code formatting. You can run `./gradlew spotlessApply` to format your code automatically. - - You can also run `./gradlew spotlessInstallGitPrePushHook --no-configuration-cache` to install a pre-push Git hook that will run a `spotlessCheck`. -- Write clear, descriptive variable and function names. -- Add comments where necessary, especially for complex logic. -- Keep methods and classes focused and concise. -- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`. - - Do **not** use the legacy `app/src/main/res/values/strings.xml`. - - **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`. - - **Usage:** - ```kotlin - import org.jetbrains.compose.resources.stringResource - import org.meshtastic.core.resources.Res - import org.meshtastic.core.resources.your_string_key - - Text(text = stringResource(Res.string.your_string_key)) - ``` - -### Linting - -Meshtastic-Android uses [Detekt](https://detekt.dev/) for static code analysis and linting of Kotlin code. - -- Run `./gradlew detekt` before submitting your pull request to ensure your code passes all lint checks. -- Fix any Detekt warnings or errors reported in your code. -- It is possible to suppress warnings individually, but this should be used very sparingly. -- You can find Detekt configuration in the `config/detekt` directory. If you believe a rule should be changed or suppressed, discuss it in your PR. - -Consistent linting helps keep the codebase clean and maintainable. - -### Testing - -Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI tests to ensure code quality and reliability. - -- **Unit tests** are located in the `src/test/` directory of each module. -- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**. - - Note: If using Java 21, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues. -- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing). - -#### Guidelines for Testing - -- Add or update tests for any new features or bug fixes. -- Ensure all tests pass by running: - - `./gradlew test` for unit and Robolectric tests - - `./gradlew connectedAndroidTest` for instrumented tests -- For UI components, write Robolectric Compose tests where possible for faster execution. -- If your change is difficult to test, explain why in your pull request. - -Comprehensive testing helps prevent regressions and ensures a stable experience for all users. - - -## Pull Requests - -- branches should start with: - - bugfix - - enhancement - - dependencies - - repo - - reserved (release, automation) -- Ensure your branch is up to date with the latest `main` branch before submitting a PR. -- Provide a meaningful title and description for your PR. -- Include information on how to test and/or replicate if it is not obvious. -- Include screenshots or logs if your change affects the UI or user experience. -- Be responsive to feedback and make requested changes promptly. -- Squash commits if requested by a maintainer. - -## Issue Reporting - -- Search existing issues before opening a new one to avoid duplicates. -- Provide a clear and descriptive title. -- Include steps to reproduce, expected behavior, and actual behavior. -- Attach logs, screenshots, or other helpful context if applicable. - -## Community Standards - -- Be respectful and considerate in all interactions. -- The Meshtastic Android project is subject to the code of conduct for the parent project, which can be [found here:](https://meshtastic.org/docs/legal/conduct/) -- Help others by reviewing pull requests and answering questions when possible. - -Thank you for helping make Meshtastic-Android better! diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 72a350afb..000000000 --- a/GEMINI.md +++ /dev/null @@ -1,6 +0,0 @@ -# Meshtastic Android - Google Gemini Guide - -> **Note:** The canonical instructions for all AI Agents have been deduplicated. - -You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`. -After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks. diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 7a118b49b..000000000 --- a/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index cf6a1b9c0..000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,238 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.8) - abbrev (0.1.2) - addressable (2.9.0) - public_suffix (>= 2.0.2, < 8.0) - artifactory (3.0.17) - atomos (0.1.3) - aws-eventstream (1.4.0) - aws-partitions (1.1240.0) - aws-sdk-core (3.245.0) - aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.992.0) - aws-sigv4 (~> 1.9) - base64 - bigdecimal - jmespath (~> 1, >= 1.6.1) - logger - aws-sdk-kms (1.123.0) - aws-sdk-core (~> 3, >= 3.244.0) - aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.219.0) - aws-sdk-core (~> 3, >= 3.244.0) - aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.5) - aws-sigv4 (1.12.1) - aws-eventstream (~> 1, >= 1.0.2) - babosa (1.0.4) - base64 (0.2.0) - benchmark (0.5.0) - bigdecimal (4.1.2) - claide (1.1.0) - colored (1.2) - colored2 (3.1.2) - commander (4.6.0) - highline (~> 2.0.0) - csv (3.3.5) - declarative (0.0.20) - digest-crc (0.7.0) - rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20240107) - dotenv (2.8.1) - emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.5) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.8) - faraday (>= 0.8.0) - http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.2.0) - multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) - fastimage (2.4.1) - fastlane (2.233.0) - CFPropertyList (>= 2.3, < 4.0.0) - abbrev (~> 0.1.2) - addressable (>= 2.8, < 3.0.0) - artifactory (~> 3.0) - aws-sdk-s3 (~> 1.197) - babosa (>= 1.0.3, < 2.0.0) - base64 (~> 0.2.0) - benchmark (>= 0.1.0) - bundler (>= 1.17.3, < 5.0.0) - colored (~> 1.2) - commander (~> 4.6) - csv (~> 3.3) - dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) - fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.1.0) - gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) - google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, <= 2.1.1) - google-cloud-storage (~> 1.31) - highline (~> 2.0) - http-cookie (~> 1.0.5) - json (< 3.0.0) - jwt (>= 2.1.0, < 3) - logger (>= 1.6, < 2.0) - mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (>= 2.0.0, < 3.0.0) - mutex_m (~> 0.3.0) - naturally (~> 2.2) - nkf (~> 0.2.0) - optparse (>= 0.1.1, < 1.0.0) - ostruct (>= 0.1.0) - plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) - simctl (~> 1.6.3) - terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) - tty-screen (>= 0.6.3, < 1.0.0) - tty-spinner (>= 0.8.0, < 1.0.0) - word_wrap (~> 1.0.0) - xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.4.1) - xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.1.0) - gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.99.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) - mini_mime (~> 1.0) - mutex_m - representable (~> 3.0) - retriable (>= 2.0, < 4.a) - google-apis-iamcredentials_v1 (0.26.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-playcustomapp_v1 (0.17.0) - google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) - google-apis-core (>= 0.15.0, < 2.a) - google-cloud-core (1.8.0) - google-cloud-env (>= 1.0, < 3.a) - google-cloud-errors (~> 1.0) - google-cloud-env (2.1.1) - faraday (>= 1.0, < 3.a) - google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-core (>= 0.18, < 2) - google-apis-iamcredentials_v1 (~> 0.18) - google-apis-storage_v1 (>= 0.42) - google-cloud-core (~> 1.6) - googleauth (~> 1.9) - mini_mime (~> 1.0) - googleauth (1.11.2) - faraday (>= 1.0, < 3.a) - google-cloud-env (~> 2.1) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - highline (2.0.3) - http-cookie (1.0.8) - domain_name (~> 0.5) - httpclient (2.9.0) - mutex_m - jmespath (1.6.2) - json (2.19.4) - jwt (2.10.2) - base64 - logger (1.7.0) - mini_magick (4.13.2) - mini_mime (1.1.5) - multi_json (1.20.1) - multipart-post (2.4.1) - mutex_m (0.3.0) - nanaimo (0.4.0) - naturally (2.3.0) - nkf (0.2.0) - optparse (0.8.1) - os (1.1.4) - ostruct (0.6.3) - plist (3.7.2) - public_suffix (7.0.5) - rake (13.4.2) - representable (3.2.0) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.4.1) - rexml (3.4.4) - rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) - signet (0.21.0) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 4.0) - multi_json (~> 1.10) - simctl (1.6.10) - CFPropertyList - naturally - terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - trailblazer-option (0.1.2) - tty-cursor (0.7.1) - tty-screen (0.8.2) - tty-spinner (0.9.3) - tty-cursor (~> 0.7) - uber (0.1.0) - unicode-display_width (2.6.0) - word_wrap (1.0.0) - xcodeproj (1.27.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.4.0) - rexml (>= 3.3.6, < 4.0) - xcpretty (0.4.1) - rouge (~> 3.28.0) - xcpretty-travis-formatter (1.0.1) - xcpretty (~> 0.2, >= 0.0.7) - -PLATFORMS - arm64-darwin-24 - ruby - -DEPENDENCIES - fastlane - -BUNDLED WITH - 2.7.2 diff --git a/README.md b/README.md index 2cc1ffe1c..07d713204 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,42 @@

- Meshtastic Logo +

Meshtastic-Android

![GitHub all releases](https://img.shields.io/github/downloads/meshtastic/meshtastic-android/total) -[![Android CI](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/pull-request.yml/badge.svg?branch=main)](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/pull-request.yml) -[![codecov](https://codecov.io/gh/meshtastic/Meshtastic-Android/graph/badge.svg)](https://codecov.io/gh/meshtastic/Meshtastic-Android) +[![Android CI](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/android.yml/badge.svg)](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/android.yml) [![Crowdin](https://badges.crowdin.net/e/f440f1a5e094a5858dd86deb1adfe83d/localized.svg)](https://crowdin.meshtastic.org/android) [![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/Meshtastic-Android)](https://cla-assistant.io/meshtastic/Meshtastic-Android) [![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/) [![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss) -This is a tool for using Android (and Compose Desktop) with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware). +This is a tool for using Android with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the the device side code, see [here](https://github.com/meshtastic/Meshtastic-device). -This project is currently beta testing across various providers. If you have questions or feedback please [Join our discussion forum](https://github.com/orgs/meshtastic/discussions) or the [Discord Group](https://discord.gg/meshtastic) . We would love to hear from you! - - - -## Get Meshtastic - -The easiest, and fastest way to get the latest beta releases is to use our [github releases](https://github.com/meshtastic/Meshtastic-Android/releases). It is recommend to use these with [Obtainium](https://github.com/ImranR98/Obtainium) to get the latest updates. - -Alternatively, these other providers are also available, but may be slower to update. +This project is currently beta testing, if you have questions or feedback +please [Join our discussion forum](https://meshtastic.discourse.group/). We would love to hear from +you! [Get it on F-Droid](https://f-droid.org/packages/com.geeksville.mesh/) +width="32%">](https://f-droid.org/packages/com.geeksville.mesh/) [Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.geeksville.mesh) -[](https://github.com/meshtastic/Meshtastic-Android/releases) +width="32%">](https://apt.izzysoft.de/fdroid/index/apk/com.geeksville.mesh) [Download at https://play.google.com/store/apps/details?id=com.geeksville.mesh]](https://play.google.com/store/apps/details?id=com.geeksville.mesh&referrer=utm_source%3Dgithub-android-readme) +width="32%">](https://play.google.com/store/apps/details?id=com.geeksville.mesh&referrer=utm_source%3Dgithub-android-readme) -The play store is the last to update of these options, but if you want to join the Play Store testing program go to [this URL](https://play.google.com/apps/testing/com.geeksville.mesh) and opt-in to become a tester. -If you encounter any problems or have questions, [ask us on the discord](https://discord.gg/meshtastic), [create an issue](https://github.com/meshtastic/Meshtastic-Android/issues), or [post in the forum](https://github.com/orgs/meshtastic/discussions) and we'll help as we can. +If you want to join the Play Store testing program go to [this URL](https://play.google.com/apps/testing/com.geeksville.mesh) and opt-in to become a tester. +If you encounter any problems or have questions, [post in the forum](https://meshtastic.discourse.group/) and we'll help. -## Documentation - -The project's documentation is generated with [Dokka](https://kotlinlang.org/docs/dokka-introduction.html) and hosted on GitHub Pages. It is automatically updated on every push to the `main` branch. - -[**View Documentation**](https://meshtastic.github.io/Meshtastic-Android/) - -### Generating Locally - -You can generate the documentation locally to preview your changes. - -1. **Run the Dokka task:** - ```bash - ./gradlew dokkaGeneratePublicationHtml - ``` -2. **View the output:** - The generated HTML files will be located in the `build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation. - -## Architecture - -### Modern Android Development (MAD) -The app follows modern Android development practices, built on top of a shared Kotlin Multiplatform (KMP) Core: -- **KMP Modules:** Business logic (`core:domain`), data sources (`core:data`, `core:database`, `core:datastore`), and communications (`core:network`, `core:ble`) are entirely platform-agnostic, targeting Android and Compose Desktop. -- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources. -- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow. -- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin). -- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking). -- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms). - -### Bluetooth Low Energy (BLE) -The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details. +However, if you must use 'raw' APKs you can get them from our [github releases](https://github.com/meshtastic/Meshtastic-Android/releases). This is not recommended because if you manually install an APK it will not automatically update. ## Translations You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android). -## API & Integration - -Developers can integrate with the Meshtastic Android app using our published API library via **JitPack**. This allows third-party applications (like the ATAK plugin) to communicate with the mesh service via AIDL. - -For detailed integration instructions, see [core/api/README.md](core/api/README.md). - -Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh. - ## Building the Android App -> [!WARNING] -> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use. https://meshtastic.org/docs/development/android/ -Note: when building the `google` flavor locally you will need to supply your own [Google Maps Android SDK api key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) `MAPS_API_KEY` in `local.properties` in order to use Google Maps. -e.g. -```properties -MAPS_API_KEY=your_google_maps_api_key_here -``` - -## Contributing guidelines - -For detailed instructions on how to contribute, please see our [CONTRIBUTING.md](CONTRIBUTING.md) file. -For details on our release process, see the [RELEASE_PROCESS.md](RELEASE_PROCESS.md) file. - -## Repository Statistics - -![Alt](https://repobeats.axiom.co/api/embed/1d75239069a6d671fe0b8f80b2e1bf590a98f0eb.svg "Repobeats analytics image") - -Copyright 2025, Meshtastic LLC. GPL-3.0 license +Copyright 2024, Meshtastic LLC. GPL-3.0 license diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md deleted file mode 100644 index 8a1feb203..000000000 --- a/RELEASE_PROCESS.md +++ /dev/null @@ -1,65 +0,0 @@ -# Meshtastic-Android Release Process - -This guide summarizes the steps for releasing a new version of Meshtastic-Android. The process is fully automated via GitHub Actions and Fastlane. - -## Overview - -The entire release process is managed by a single, manually-triggered GitHub Action: **`Create or Promote Release`**. - -- **Trigger:** To start a new release or promote an existing one, a developer manually runs the workflow from the GitHub Actions tab. -- **Inputs:** The workflow requires two inputs: - 1. `version`: The base version number you are releasing (e.g., `2.4.0`). - 2. `channel`: The release channel you are targeting (`internal`, `closed`, `open`, or `production`). -- **Automation:** The workflow handles everything automatically: - - **Syncs Assets:** Fetches the latest firmware/hardware lists, protobuf definitions, and translations (Crowdin). - - **Generates Changelog:** Creates a clean changelog from commits since the last production release and commits it to the repo. - - **Updates Config:** Automatically bumps the `VERSION_NAME_BASE` in `config.properties`. - - **Verifies & Tags:** Runs lint checks, builds the app, and *only* tags the release if successful. - - **Deploys:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release. -- **Changelog:** Release notes are auto-generated from PR labels. Ensure PRs are labeled correctly to maintain an accurate changelog. - -## Release Steps - -### 1. Start an Internal Release - -1. Navigate to the **Actions** tab in the GitHub repository. -2. Select the **`Create or Promote Release`** workflow. -3. Click the **"Run workflow"** dropdown. -4. Enter the base `version` (e.g., `2.4.0`). -5. Select the `internal` channel. -6. Click **"Run workflow"**. - -The workflow will: -1. **Create a new commit** on the current branch containing updated assets, translations, and the new changelog. -2. **Tag** that commit with an incremental internal tag (e.g., `v2.4.0-internal.1`). -3. **Build & Deploy** the verified artifact to the Play Store Internal track. -4. Publish a **draft** pre-release on GitHub. - -### 2. Promote to the Next Channel - -Once an internal build has been verified, you can promote it to a wider audience. - -1. Run the **`Create or Promote Release`** workflow again with the same base `version`. -2. Select the next channel in the sequence (e.g., `closed`, then `open`). -3. The workflow will create a new incremental tag for that channel (e.g., `v2.4.0-closed.1`) and create a **published** pre-release on GitHub. - -### 3. Promote to Production - -After testing is complete on all pre-release channels, you can create the final public release. - -1. Run the **`Create or Promote Release`** workflow one last time. -2. Use the same base `version`. -3. Select the `production` channel. -4. The workflow will create a clean version tag (e.g., `v2.4.0`) and create a **published, stable** (non-prerelease) release on GitHub. - -### 4. Post-Release - -1. **Verify:** Check the Google Play Console to ensure the build is available on the correct track. -2. **Merge:** Merge the release branch (if one was used for stabilization) back into `main`. - -## Build Attestations & Provenance - -All release artifacts are accompanied by explicit GitHub build attestations (provenance). This provides cryptographic proof that the artifacts were built by our trusted GitHub Actions workflow, ensuring supply chain integrity. - -- You can view and verify provenance in the GitHub UI under each release asset. -- For more details, see [GitHub's documentation on build provenance](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#provenance-attestations). diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index dc4df33df..000000000 --- a/SECURITY.md +++ /dev/null @@ -1,12 +0,0 @@ -# Security Policy - -## Supported Versions - -| App Version | Supported | -| ---------------- | ------------------ | -| 2.7.x | :white_check_mark: | -| <= 2.6.x | :x: | - -## Reporting a Vulnerability - -We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..7863aa700 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +/build +/debug +/release +google-services.json diff --git a/app/README.md b/app/README.md deleted file mode 100644 index ff6f5542f..000000000 --- a/app/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# `:app` - -## Overview -The `:app` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell. - -## Key Components - -### 1. `MainActivity` & `Main.kt` -The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.). - -### 2. `MeshService` -The core background service that manages long-running communication with the mesh radio. While it is declared in the `:app` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background. - -### 3. Koin Application -`MeshUtilApplication` is the Koin entry point, providing the global dependency injection container. - -## Architecture -The module primarily serves as a "glue" layer, connecting: -- `core:*` modules for shared logic. -- `feature:*` modules for specific user-facing screens. - -## Module dependency graph - - -```mermaid -graph TB - :app[app]:::android-application - :app -.-> :core:ble - :app -.-> :core:common - :app -.-> :core:data - :app -.-> :core:database - :app -.-> :core:datastore - :app -.-> :core:di - :app -.-> :core:domain - :app -.-> :core:model - :app -.-> :core:navigation - :app -.-> :core:network - :app -.-> :core:nfc - :app -.-> :core:prefs - :app -.-> :core:proto - :app -.-> :core:service - :app -.-> :core:resources - :app -.-> :core:ui - :app -.-> :core:barcode - :app -.-> :core:takserver - :app -.-> :feature:intro - :app -.-> :feature:messaging - :app -.-> :feature:connections - :app -.-> :feature:map - :app -.-> :feature:node - :app -.-> :feature:settings - :app -.-> :feature:firmware - :app -.-> :feature:wifi-provision - :app -.-> :feature:widget - -classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; -classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; -classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; -classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; -classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000; -classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000; -classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000; -classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000; -classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; -classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000; -classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000; -classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; - -``` - diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..3fb2f14a9 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,283 @@ +plugins { + id 'com.android.application' + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.plugin.compose" + id "org.jetbrains.kotlin.plugin.parcelize" + id 'kotlinx-serialization' + id 'com.google.dagger.hilt.android' + id 'com.google.protobuf' + id "com.google.devtools.ksp" + id "io.gitlab.arturbosch.detekt" version "1.23.7" +} + +def keystorePropertiesFile = rootProject.file("keystore.properties") +def keystoreProperties = new Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace 'com.geeksville.mesh' + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + compileSdk 35 + defaultConfig { + applicationId "com.geeksville.mesh" + minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) + targetSdk 34 + versionCode 30508 // format is Mmmss (where M is 1+the numeric major number + versionName "2.5.8" + testInstrumentationRunner "com.geeksville.mesh.TestRunner" + + // per https://developer.android.com/studio/write/vector-asset-studio + vectorDrawables.useSupportLibrary = true + } + flavorDimensions = ['default'] + productFlavors { + fdroid { + dimension = 'default' + dependenciesInfo { + includeInApk = false + } + } + google { + dimension = 'default' + if (useCrashlytics) { + apply plugin: 'com.google.gms.google-services' + apply plugin: 'com.google.firebase.crashlytics' + } + } + } + buildTypes { + release { + if (keystoreProperties['storeFile']) { + signingConfig signingConfigs.release + } + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + pseudoLocalesEnabled true + } + } + defaultConfig { + // We have to list all translated languages here, because some of our libs have bogus languages that google play + // doesn't like and we need to strip them (gr) + resConfigs "bg", "ca", "cs", "de", "el", "en", "es", "et", "fi", "fr", "fr-rHT", "ga", "gl", "hr", "hu", "is", "it", "iw", "ja", "ko", "lt", "nl", "nb", "pl", "pt", "pt-rBR", "ro", "ru", "sk", "sl", "sq", "sv", "tr", "zh-rCN", "zh-rTW", "uk" + + ndk { + // abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + } + } + bundle { + language { + enableSplit false + } + } + buildFeatures { + viewBinding true + compose true + aidl true + buildConfig true + } + // Configure the build-logic plugins to target JDK 17 + // This matches the JDK used to build the project, and is not related to what is running on device. + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + freeCompilerArgs += ['-opt-in=kotlin.RequiresOptIn'] + } + lint { + abortOnError false + } + sourceSets { + // Adds exported schema location as test app assets. + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } +} + +// per protobuf-gradle-plugin docs, this is recommended for android +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobuf_version" + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + // turned off for now so I can use json printing in debug panel + // use the smaller android version of the library + //option "lite" + } + kotlin { + } + } + } + } +} + +// workaround for https://github.com/google/ksp/issues/1590 +androidComponents { + onVariants(selector().all(), { variant -> + afterEvaluate { + def capName = variant.name.capitalize() + tasks.getByName("ksp${capName}Kotlin") { + setSource(tasks.getByName("generate${capName}Proto").outputs) + } + } + }) +} + +dependencies { + + implementation fileTree(dir: 'libs', include: ['*.jar']) + def appcompat_version = '1.7.0' + implementation "androidx.appcompat:appcompat:$appcompat_version" + // For loading and tinting drawables on older versions of the platform + implementation "androidx.appcompat:appcompat-resources:$appcompat_version" + implementation "androidx.emoji2:emoji2-emojipicker:1.5.0" + + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.core:core-location-altitude:1.0.0-alpha03' + implementation 'androidx.fragment:fragment-ktx:1.8.5' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.viewpager2:viewpager2:1.1.0' + implementation 'androidx.datastore:datastore:1.1.1' + + // Lifecycle + def lifecycle_version = '2.8.7' + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version" + + // Room + def room_version = '2.6.1' + implementation "androidx.room:room-runtime:$room_version" + ksp "androidx.room:room-compiler:$room_version" + // optional - Kotlin Extensions and Coroutines support for Room + implementation "androidx.room:room-ktx:$room_version" + // optional - Test helpers + androidTestImplementation "androidx.room:room-testing:$room_version" + + // Hilt + implementation "com.google.dagger:hilt-android:$hilt_version" + implementation "androidx.hilt:hilt-navigation-compose:1.2.0" + ksp "com.google.dagger:hilt-compiler:$hilt_version" + androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" + kspAndroidTest "com.google.dagger:hilt-compiler:$hilt_version" + + // Navigation + def nav_version = "2.8.2" + implementation "androidx.navigation:navigation-compose:$nav_version" + androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" + + // Compose + def composeBom = platform('androidx.compose:compose-bom:2024.10.01') + implementation composeBom + androidTestImplementation composeBom + + implementation 'androidx.compose.material:material' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.activity:activity-compose' + implementation 'androidx.compose.runtime:runtime-livedata' + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.36.0" + + // Android Studio Preview support + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // UI Tests + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + + // Osmdroid & Maps + def osmdroid_version = '6.1.14' + implementation "org.osmdroid:osmdroid-android:$osmdroid_version" + implementation "org.osmdroid:osmdroid-wms:$osmdroid_version" + implementation("org.osmdroid:osmdroid-geopackage:$osmdroid_version") { + exclude group: 'com.j256.ormlite' + } + implementation 'com.github.MKergall:osmbonuspack:6.9.0' + implementation "mil.nga:mgrs:2.1.3" + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + // kotlin serialization + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + + // rate this app + googleImplementation 'com.suddenh4x.ratingdialog:awesome-app-rating:2.7.0' + + // Coroutines + def coroutines_version = '1.9.0' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version" + + // For now I'm not using javalite, because I want JSON printing + implementation "com.google.protobuf:protobuf-kotlin:$protobuf_version" + + // For UART access + implementation 'com.github.mik3y:usb-serial-for-android:3.8.1' + + // For Firebase Crashlytics & Analytics + googleImplementation platform('com.google.firebase:firebase-bom:33.5.1') + googleImplementation 'com.google.firebase:firebase-crashlytics' + googleImplementation 'com.google.firebase:firebase-analytics' + + // barcode support + // per https://github.com/journeyapps/zxing-android-embedded#older-sdk-versions for minSdkVersion 21 + implementation('com.journeyapps:zxing-android-embedded:4.3.0') { transitive = false } + //noinspection GradleDependency + implementation 'com.google.zxing:core:3.3.0' // <-- don't update + + def work_version = '2.9.1' + // Work Request - used to delay boot event handling + implementation "androidx.work:work-runtime-ktx:$work_version" + + implementation "androidx.core:core-splashscreen:1.0.1" + + // CompletableFuture backport for API 14+ + implementation 'net.sourceforge.streamsupport:streamsupport-minifuture:1.7.4' + + // App intro + implementation 'com.github.AppIntro:AppIntro:6.3.1' + + // MQTT + implementation "org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5" + + // detekt ktlint formatting + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7") +} + +ksp { +// arg("room.generateKotlin", "true") + arg("room.schemaLocation", "$projectDir/schemas") +} + +repositories { + maven { url "https://jitpack.io" } +} + +detekt { + config.setFrom("../config/detekt/detekt.yml") + baseline = file("../config/detekt/detekt-baseline.xml") +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index d239d0530..000000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import com.android.build.api.dsl.ApplicationExtension -import com.mikepenz.aboutlibraries.plugin.DuplicateMode -import com.mikepenz.aboutlibraries.plugin.DuplicateRule -import org.meshtastic.buildlogic.GitVersionValueSource -import org.meshtastic.buildlogic.configProperties -import java.io.FileInputStream -import java.util.Properties - -val gitVersionProvider = providers.of(GitVersionValueSource::class.java) {} - -plugins { - alias(libs.plugins.meshtastic.android.application) - alias(libs.plugins.meshtastic.android.application.flavors) - alias(libs.plugins.meshtastic.android.application.compose) - id("meshtastic.koin") - alias(libs.plugins.kotlin.parcelize) - alias(libs.plugins.secrets) - alias(libs.plugins.aboutlibraries) - id("dev.mokkery") -} - -val keystorePropertiesFile = rootProject.file("keystore.properties") -val keystoreProperties = Properties() - -if (keystorePropertiesFile.exists()) { - FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) } -} - -configure { - namespace = "org.meshtastic.app" - - signingConfigs { - create("release") { - keyAlias = keystoreProperties["keyAlias"] as String? - keyPassword = keystoreProperties["keyPassword"] as String? - storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String? - } - } - defaultConfig { - applicationId = configProperties.getProperty("APPLICATION_ID") - - val vcOffset = configProperties.getProperty("VERSION_CODE_OFFSET")?.toInt() ?: 0 - println("Version code offset: $vcOffset") - versionCode = - ( - project.findProperty("android.injected.version.code")?.toString()?.toInt() - ?: System.getenv("VERSION_CODE")?.toInt() - ?: (gitVersionProvider.get().toInt() + vcOffset) - ) - versionName = - ( - project.findProperty("android.injected.version.name")?.toString() - ?: System.getenv("VERSION_NAME") - ?: configProperties.getProperty("VERSION_NAME_BASE") - ) - buildConfigField("String", "MIN_FW_VERSION", "\"${configProperties.getProperty("MIN_FW_VERSION")}\"") - buildConfigField("String", "ABS_MIN_FW_VERSION", "\"${configProperties.getProperty("ABS_MIN_FW_VERSION")}\"") - // We have to list all translated languages here, - // because some of our libs have bogus languages that google play - // doesn't like and we need to strip them (gr) - @Suppress("UnstableApiUsage") - val ci = project.findProperty("ci")?.toString()?.toBoolean() ?: false - if (ci) { - println("CI build detected - limiting locale filters for faster packaging") - androidResources.localeFilters.addAll(listOf("en")) - } else { - androidResources.localeFilters.addAll( - listOf( - "en", - "ar", - "bg", - "ca", - "cs", - "de", - "el", - "es", - "et", - "fi", - "fr", - "ga", - "gl", - "hr", - "ht", - "hu", - "is", - "it", - "iw", - "ja", - "ko", - "lt", - "nl", - "no", - "pl", - "pt", - "pt-rBR", - "ro", - "ru", - "sk", - "sl", - "sq", - "sr", - "srp", - "sv", - "tr", - "uk", - "zh-rCN", - "zh-rTW", - ), - ) - } - ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") } - - val disableSplits = - project.gradle.startParameter.taskNames.any { - it.contains("bundle", ignoreCase = true) || it.contains("google", ignoreCase = true) - } - - // Enable ABI splits to generate smaller APKs per architecture for F-Droid/IzzyOnDroid - splits { - abi { - isEnable = !disableSplits - reset() - include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") - isUniversalApk = true - } - } - - dependenciesInfo { - // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) - includeInApk = false - // Disables dependency metadata when building Android App Bundles (for Google Play) - includeInBundle = false - } - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - // Configure existing product flavors (defined by convention plugin) - // with their dynamic version names. - productFlavors { - configureEach { - versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name" - if (name == "google") { - manifestPlaceholders["MAPS_API_KEY"] = "dummy" - } - } - } - - buildTypes { - release { - if (keystoreProperties["storeFile"] != null) { - signingConfig = signingConfigs.named("release").get() - } else { - signingConfig = signingConfigs.getByName("debug") - } - isDebuggable = false - } - } - bundle { language { enableSplit = false } } - - testOptions { unitTests { isIncludeAndroidResources = true } } -} - -secrets { - defaultPropertiesFileName = "secrets.defaults.properties" - propertiesFileName = "secrets.properties" -} - -androidComponents { - onVariants(selector().withBuildType("debug")) { variant -> - variant.flavorName?.let { flavor -> variant.applicationId = "com.geeksville.mesh.$flavor.debug" } - } - - onVariants(selector().withBuildType("release")) { variant -> - if (variant.flavorName == "google") { - val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() } - val minifyTaskName = "minify${variantNameCapped}WithR8" - val uploadTaskName = "uploadMapping$variantNameCapped" - if (project.tasks.findByName(uploadTaskName) != null && project.tasks.findByName(minifyTaskName) != null) { - tasks.named(minifyTaskName).configure { finalizedBy(uploadTaskName) } - } - } - } -} - -project.afterEvaluate { - logger.lifecycle( - "Version code is set to: ${extensions.getByType().defaultConfig.versionCode}", - ) -} - -dependencies { - implementation(projects.core.ble) - implementation(projects.core.common) - implementation(projects.core.data) - implementation(projects.core.database) - implementation(projects.core.datastore) - implementation(projects.core.di) - implementation(projects.core.domain) - implementation(projects.core.model) - implementation(projects.core.navigation) - implementation(libs.jetbrains.lifecycle.viewmodel.navigation3) - implementation(projects.core.network) - implementation(projects.core.nfc) - implementation(projects.core.prefs) - implementation(projects.core.proto) - implementation(projects.core.service) - implementation(projects.core.resources) - implementation(projects.core.ui) - implementation(projects.core.barcode) - implementation(projects.core.takserver) - implementation(projects.feature.intro) - implementation(projects.feature.messaging) - implementation(projects.feature.connections) - implementation(projects.feature.map) - implementation(projects.feature.node) - implementation(projects.feature.settings) - implementation(projects.feature.firmware) - implementation(projects.feature.wifiProvision) - implementation(projects.feature.widget) - - implementation(libs.jetbrains.compose.material3.adaptive) - implementation(libs.jetbrains.compose.material3.adaptive.layout) - implementation(libs.jetbrains.compose.material3.adaptive.navigation) - implementation(libs.material) - implementation(libs.compose.multiplatform.animation) - implementation(libs.compose.multiplatform.material3) - implementation(libs.compose.multiplatform.ui.tooling.preview) - implementation(libs.compose.multiplatform.ui) - implementation(libs.androidx.glance.appwidget) - implementation(libs.androidx.glance.appwidget.preview) - implementation(libs.androidx.glance.material3) - implementation(libs.androidx.lifecycle.process) - implementation(libs.jetbrains.lifecycle.viewmodel.compose) - implementation(libs.jetbrains.lifecycle.runtime.compose) - implementation(libs.jetbrains.navigation3.ui) - implementation(libs.ktor.client.android) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.ktor.client.logging) - implementation(libs.coil) - implementation(libs.coil.network.ktor3) - implementation(libs.coil.svg) - implementation(libs.androidx.core.splashscreen) - implementation(libs.kotlinx.serialization.json) - implementation(libs.usb.serial.android) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.koin.android) - implementation(libs.koin.compose.viewmodel) - implementation(libs.koin.androidx.workmanager) - implementation(libs.koin.annotations) - implementation(libs.accompanist.permissions) - implementation(libs.kermit) - implementation(libs.kotlinx.datetime) - - debugImplementation(libs.androidx.compose.ui.test.manifest) - debugImplementation(libs.androidx.glance.preview) - - googleImplementation(libs.location.services) - googleImplementation(libs.play.services.maps) - googleImplementation(libs.maps.compose) - googleImplementation(libs.maps.compose.utils) - googleImplementation(libs.maps.compose.widgets) - googleImplementation(libs.dd.sdk.android.logs) - googleImplementation(libs.dd.sdk.android.rum) - googleImplementation(libs.dd.sdk.android.session.replay) - googleImplementation(libs.dd.sdk.android.session.replay.material) - googleImplementation(libs.dd.sdk.android.timber) - googleImplementation(libs.dd.sdk.android.trace) - googleImplementation(libs.dd.sdk.android.trace.otel) - googleImplementation(platform(libs.firebase.bom)) - googleImplementation(libs.firebase.analytics) - googleImplementation(libs.firebase.crashlytics) - - fdroidImplementation(libs.osmdroid.android) - fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } - fdroidImplementation(libs.osmbonuspack) - - testImplementation(kotlin("test-junit")) - testImplementation(libs.androidx.work.testing) - testImplementation(libs.koin.test) - testRuntimeOnly(libs.junit.vintage.engine) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.androidx.test.core) - testImplementation(libs.compose.multiplatform.ui.test) - testImplementation(libs.androidx.test.ext.junit) - testImplementation(libs.androidx.glance.appwidget) -} - -aboutLibraries { - // Run offline by default to avoid burning GitHub API calls on every build. - // Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info. - val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false) - val ghToken = providers.environmentVariable("GITHUB_TOKEN") - - offlineMode = !isReleaseBuild - - collect { - fetchRemoteLicense = isReleaseBuild && ghToken.isPresent - fetchRemoteFunding = isReleaseBuild && ghToken.isPresent - if (ghToken.isPresent) { - gitHubApiToken = ghToken.get() - } - } - export { - excludeFields = listOf("generated") - outputFile = file("src/main/resources/aboutlibraries.json") - } - library { - duplicationMode = DuplicateMode.MERGE - duplicationRule = DuplicateRule.SIMPLE - } -} - -// Ensure aboutlibraries.json is always up-to-date during the build. -// This is required since AboutLibraries v11+ no longer auto-exports. -tasks - .matching { it.name.startsWith("process") && it.name.endsWith("Resources") } - .configureEach { dependsOn("exportLibraryDefinitions") } diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml deleted file mode 100644 index c373eea43..000000000 --- a/app/detekt-baseline.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/google-services.json b/app/google-services.json index 73d076ce6..d44e7ae87 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -48,19 +48,6 @@ ] } } - }, - { - "client_info": { - "mobilesdk_app_id": "1:xxx:android:1111", - "android_client_info": { - "package_name": "com.geeksville.mesh.google.debug" - } - }, - "api_key": [ - { - "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" - } - ] } ], "configuration_version": "1" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index de2b3144c..77aa35790 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,45 +1,46 @@ -# ============================================================================ -# Meshtastic Android — ProGuard / R8 rules for release minification -# ============================================================================ -# Open-source project: obfuscation and optimization are disabled. We rely on -# tree-shaking (unused code removal) for APK size reduction. +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. # -# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, -# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, -# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in -# config/proguard/shared-rules.pro and are wired in by the -# AndroidApplicationConventionPlugin. This file holds only Android-specific -# rules and R8-only directives. -# ============================================================================ +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html -# ---- General ---------------------------------------------------------------- +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} -# Open-source — no need to obfuscate --dontobfuscate +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable -# Disable R8 optimization passes. Tree-shaking (unused code removal) still -# runs — only method-body rewrites and call-site transformations are suppressed. -# -# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on -# Composer.() and ComposerImpl.(), plus -assumevalues on -# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives -# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the -# target classes are preserved by -keep rules. The result is that the Compose -# recomposer/frame-clock/animation state machines silently freeze on their -# first frame in release builds. -dontoptimize is the only directive that -# disables processing of -assumenosideeffects/-assumevalues. See #5146. --dontoptimize +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile -# Dump the full merged R8 configuration (app rules + all library consumer rules) -# for auditing. Inspect this file after a release build to see what libraries inject. --printconfiguration build/outputs/mapping/r8-merged-config.txt +# Needed for protobufs +-keep class com.google.protobuf.** { *; } +-keep class com.geeksville.mesh.** { *; } -# ---- Networking (transitive references from Ktor on Android) ---------------- +# eclipse.paho.client +-keep class org.eclipse.paho.client.mqttv3.logging.JSR47Logger { *; } +# ormlite +-dontwarn com.j256.ormlite.** + +# OkHttp +-dontwarn okhttp3.internal.platform.** -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** -# Compose runtime/ui/animation/foundation/material3 keep rules now live in -# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard) -# get the same defence-in-depth coverage against CMP 1.11 optimizer folding. +# ? +-dontwarn java.awt.image.** +-dontwarn java.lang.reflect.** +-dontwarn com.google.errorprone.annotations.** + +# Our app is opensource no need to obsfucate +-dontobfuscate +-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/10.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/10.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/11.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/11.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/11.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/11.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/12.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/12.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/12.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/12.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/13.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/13.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/13.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/13.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/3.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/3.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/3.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/3.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/4.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/4.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/4.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/4.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/5.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/5.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/5.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/5.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/6.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/6.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/6.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/6.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/7.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/7.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/8.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/8.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/9.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/9.json similarity index 100% rename from core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/9.json rename to app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/9.json diff --git a/app/src/androidTest/java/com/geeksville/mesh/ChannelSetTest.kt b/app/src/androidTest/java/com/geeksville/mesh/ChannelSetTest.kt new file mode 100644 index 000000000..0832f4abf --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/ChannelSetTest.kt @@ -0,0 +1,64 @@ +package com.geeksville.mesh + +import android.net.Uri +import com.geeksville.mesh.model.getChannelUrl +import com.geeksville.mesh.model.primaryChannel +import com.geeksville.mesh.model.shouldAddChannels +import com.geeksville.mesh.model.toChannelSet +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class ChannelSetTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Before + fun init() { + hiltRule.inject() + } + + /** make sure we match the python and device code behavior */ + @Test + fun matchPython() { + val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ") + val cs = url.toChannelSet() + Assert.assertEquals("LongFast", cs.primaryChannel!!.name) + Assert.assertEquals(url, cs.getChannelUrl(false)) + } + + /** validate against the host or path in a case-insensitive way */ + @Test + fun parseCaseInsensitive() { + var url = Uri.parse("HTTPS://MESHTASTIC.ORG/E/#CgMSAQESBggBQANIAQ") + Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name) + + url = Uri.parse("HTTPS://mEsHtAsTiC.OrG/e/#CgMSAQESBggBQANIAQ") + Assert.assertEquals("LongFast", url.toChannelSet().primaryChannel!!.name) + } + + /** properly parse channel config when `?add=true` is in the fragment */ + @Test + fun handleAddInFragment() { + val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ?add=true") + val cs = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() + Assert.assertEquals("LongFast", cs.primaryChannel!!.name) + Assert.assertTrue(shouldAdd) + } + + /** properly parse channel config when `?add=true` is in the query parameters */ + @Test + fun handleAddInQueryParams() { + val url = Uri.parse("https://meshtastic.org/e/?add=true#CgMSAQESBggBQANIAQ") + val cs = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() + Assert.assertEquals("LongFast", cs.primaryChannel!!.name) + Assert.assertTrue(shouldAdd) + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt b/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt new file mode 100644 index 000000000..d0f814eb0 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/ChannelTest.kt @@ -0,0 +1,54 @@ +package com.geeksville.mesh + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.URL_PREFIX +import com.geeksville.mesh.model.getChannelUrl +import com.geeksville.mesh.model.numChannels +import com.geeksville.mesh.model.toChannelSet +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelTest { + @Test + fun channelUrlGood() { + val ch = channelSet { + settings.add(Channel.default.settings) + loraConfig = Channel.default.loraConfig + } + val channelUrl = ch.getChannelUrl() + + Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX)) + Assert.assertEquals(channelUrl.toChannelSet(), ch) + } + + @Test + fun channelHashGood() { + val ch = Channel.default + + Assert.assertEquals(8, ch.hash) + } + + @Test + fun numChannelsGood() { + val ch = Channel.default + + Assert.assertEquals(104, ch.loraConfig.numChannels) + } + + @Test + fun channelNumGood() { + val ch = Channel.default + + Assert.assertEquals(20, ch.channelNum) + } + + @Test + fun radioFreqGood() { + val ch = Channel.default + + Assert.assertEquals(906.875f, ch.radioFreq) + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/geeksville/mesh/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..2e96d2f76 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.geeksville.mesh + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.geeksville.mesh", appContext.packageName) + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/MeshtasticDatabaseTest.kt b/app/src/androidTest/java/com/geeksville/mesh/MeshtasticDatabaseTest.kt new file mode 100644 index 000000000..8990554f0 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/MeshtasticDatabaseTest.kt @@ -0,0 +1,44 @@ +package com.geeksville.mesh + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.database.MeshtasticDatabase +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MeshtasticDatabaseTest { + + companion object { + private const val TEST_DB = "migration-test" + } + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + MeshtasticDatabase::class.java, + ) + + @Test + @Throws(IOException::class) + fun migrateAll() { + // Create earliest version of the database. + helper.createDatabase(TEST_DB, 3).apply { + close() + } + + // Open latest version of the database. Room validates the schema + // once all migrations execute. + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + MeshtasticDatabase::class.java, + TEST_DB + ).build().apply { + openHelper.writableDatabase.close() + } + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt new file mode 100644 index 000000000..1879a828a --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt @@ -0,0 +1,174 @@ +package com.geeksville.mesh + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.database.MeshtasticDatabase +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.NodeSortOption +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NodeInfoDaoTest { + private lateinit var database: MeshtasticDatabase + private lateinit var nodeInfoDao: NodeInfoDao + + private val ourNode = NodeEntity( + num = 8, + user = user { + id = "+16508765308".format(8) + longName = "Kevin Mester" + shortName = "KLO" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + isLicensed = false + }, + longName = "Kevin Mester", shortName = "KLO", + latitude = 30.267153, longitude = -97.743057 // Austin + ) + + private val myNodeInfo: MyNodeEntity = MyNodeEntity( + myNodeNum = ourNode.num, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 1L, + messageTimeoutMsec = 5 * 60 * 1000, + minAppVersion = 1, + maxChannels = 8, + hasWifi = false, + ) + + private val testPositions = arrayOf( + 0.0 to 0.0, + 32.776665 to -96.796989, // Dallas + 32.960758 to -96.733521, // Richardson + 32.912901 to -96.781776, // North Dallas + 29.760427 to -95.369804, // Houston + 33.748997 to -84.387985, // Atlanta + 34.052235 to -118.243683, // Los Angeles + 40.712776 to -74.005974, // New York City + 41.878113 to -87.629799, // Chicago + 39.952583 to -75.165222, // Philadelphia + ) + + private val testNodes = listOf(ourNode) + testPositions.mapIndexed { index, pos -> + NodeEntity( + num = 9 + index, + user = user { + id = "+165087653%02d".format(9 + index) + longName = "Kevin Mester$index" + shortName = "KM$index" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + isLicensed = false + }, + longName = "Kevin Mester$index", shortName = if (index == 2) null else "KM$index", + latitude = pos.first, longitude = pos.second, + lastHeard = 9 + index, + ) + } + + @Before + fun createDb(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build() + nodeInfoDao = database.nodeInfoDao() + + nodeInfoDao.apply { + putAll(testNodes) + setMyNodeInfo(myNodeInfo) + } + } + + @After + fun closeDb() { + database.close() + } + + /** + * Retrieves a list of nodes based on [sort], [filter] and [includeUnknown] parameters. + * The list excludes [ourNode] to ensure consistency in the results. + */ + private suspend fun getNodes( + sort: NodeSortOption = NodeSortOption.LAST_HEARD, + filter: String = "", + includeUnknown: Boolean = true, + ) = nodeInfoDao.getNodes( + sort = sort.sqlValue, + filter = filter, + includeUnknown = includeUnknown, + ).first().filter { it != ourNode } + + @Test // node list size + fun testNodeListSize() = runBlocking { + val nodes = nodeInfoDao.nodeDBbyNum().first() + assertEquals(11, nodes.size) + } + + @Test // nodeDBbyNum() re-orders our node at the top of the list + fun testOurNodeInfoIsFirst() = runBlocking { + val nodes = nodeInfoDao.nodeDBbyNum().first() + assertEquals(ourNode, nodes.values.first()) + } + + @Test + fun testSortByLastHeard() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.LAST_HEARD) + val sortedNodes = nodes.sortedByDescending { it.lastHeard } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByAlpha() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL) + val sortedNodes = nodes.sortedBy { it.user.longName.uppercase() } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByDistance() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.DISTANCE) + val sortedNodes = nodes.sortedWith( // nodes with invalid (null) positions at the end + compareBy { it.validPosition == null }.thenBy { it.distance(ourNode) } + ) + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByChannel() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.CHANNEL) + val sortedNodes = nodes.sortedBy { it.channel } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testSortByViaMqtt() = runBlocking { + val nodes = getNodes(sort = NodeSortOption.VIA_MQTT) + val sortedNodes = nodes.sortedBy { it.user.longName.contains("(MQTT)") } + assertEquals(sortedNodes, nodes) + } + + @Test + fun testIncludeUnknownIsFalse() = runBlocking { + val nodes = getNodes(includeUnknown = false) + val containsUnsetNode = nodes.any { it.isUnknownUser } + assertFalse(containsUnsetNode) + } + + @Test + fun testIncludeUnknownIsTrue() = runBlocking { + val nodes = getNodes(includeUnknown = true) + val containsUnsetNode = nodes.any { it.shortName == null } + assertTrue(containsUnsetNode) + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt new file mode 100644 index 000000000..6f2240d0f --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt @@ -0,0 +1,157 @@ +package com.geeksville.mesh + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.database.MeshtasticDatabase +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +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 + +@RunWith(AndroidJUnit4::class) +class PacketDaoTest { + private lateinit var database: MeshtasticDatabase + private lateinit var nodeInfoDao: NodeInfoDao + private lateinit var packetDao: PacketDao + + private val myNodeInfo: MyNodeEntity = MyNodeEntity( + myNodeNum = 42424242, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 1L, + messageTimeoutMsec = 5 * 60 * 1000, + minAppVersion = 1, + maxChannels = 8, + hasWifi = false, + ) + + private val myNodeNum: Int get() = myNodeInfo.myNodeNum + + private val testContactKeys = listOf( + "0${DataPacket.ID_BROADCAST}", + "1!test1234", + ) + + private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey -> + List(SAMPLE_SIZE) { + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = contactKey, + received_time = System.currentTimeMillis(), + read = false, + DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"), + ) + } + } + + @Before + fun createDb(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build() + + nodeInfoDao = database.nodeInfoDao().apply { + setMyNodeInfo(myNodeInfo) + } + + packetDao = database.packetDao().apply { + generateTestPackets(42424243).forEach(::insert) + generateTestPackets(myNodeNum).forEach(::insert) + } + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun test_myNodeNum() = runBlocking { + val myNodeInfo = nodeInfoDao.getMyNodeInfo().first() + assertEquals(myNodeNum, myNodeInfo?.myNodeNum) + } + + @Test + fun test_getAllPackets() = runBlocking { + val packets = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first() + assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size) + + val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + + @Test + fun test_getContactKeys() = runBlocking { + val contactKeys = packetDao.getContactKeys().first() + assertEquals(testContactKeys.size, contactKeys.size) + + val onlyMyNodeNum = contactKeys.values.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + + @Test + fun test_getMessageCount() = runBlocking { + testContactKeys.forEach { contactKey -> + val messageCount = packetDao.getMessageCount(contactKey) + assertEquals(SAMPLE_SIZE, messageCount) + } + } + + @Test + fun test_getMessagesFrom() = runBlocking { + testContactKeys.forEach { contactKey -> + val messages = packetDao.getMessagesFrom(contactKey).first() + assertEquals(SAMPLE_SIZE, messages.size) + + val onlyFromContactKey = messages.all { it.contact_key == contactKey } + assertTrue(onlyFromContactKey) + + val onlyMyNodeNum = messages.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + } + + @Test + fun test_getUnreadCount() = runBlocking { + testContactKeys.forEach { contactKey -> + val unreadCount = packetDao.getUnreadCount(contactKey) + assertEquals(SAMPLE_SIZE, unreadCount) + } + } + + @Test + fun test_clearUnreadCount() = runBlocking { + val timestamp = System.currentTimeMillis() + testContactKeys.forEach { contactKey -> + packetDao.clearUnreadCount(contactKey, timestamp) + val unreadCount = packetDao.getUnreadCount(contactKey) + assertEquals(0, unreadCount) + } + } + + @Test + fun test_deleteContacts() = runBlocking { + packetDao.deleteContacts(testContactKeys) + + testContactKeys.forEach { contactKey -> + val messages = packetDao.getMessagesFrom(contactKey).first() + assertTrue(messages.isEmpty()) + } + } + + companion object { + private const val SAMPLE_SIZE = 10 + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt b/app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt new file mode 100644 index 000000000..dae86c664 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/TestRunner.kt @@ -0,0 +1,13 @@ +package com.geeksville.mesh + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +@Suppress("unused") +class TestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/EditDeviceProfileDialogTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/EditDeviceProfileDialogTest.kt new file mode 100644 index 000000000..6fdf77877 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/EditDeviceProfileDialogTest.kt @@ -0,0 +1,99 @@ +package com.geeksville.mesh.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.R +import com.geeksville.mesh.deviceProfile +import com.geeksville.mesh.position +import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EditDeviceProfileDialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun getString(id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + private val title = "Export configuration" + private val deviceProfile = deviceProfile { + longName = "Long name" + shortName = "Short name" + channelUrl = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ" + fixedPosition = position { + latitudeI = 327766650 + longitudeI = -967969890 + altitude = 138 + } + } + + private fun testEditDeviceProfileDialog( + onDismiss: () -> Unit = {}, + onConfirm: (DeviceProfile) -> Unit = {}, + ) = composeTestRule.setContent { + EditDeviceProfileDialog( + title = title, + deviceProfile = deviceProfile, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) + } + + @Test + fun testEditDeviceProfileDialog_showsDialogTitle() { + composeTestRule.apply { + testEditDeviceProfileDialog() + + // Verify that the dialog title is displayed + onNodeWithText(title).assertIsDisplayed() + } + } + + @Test + fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() { + composeTestRule.apply { + testEditDeviceProfileDialog() + + // Verify the "Cancel" and "Save" buttons are displayed + onNodeWithText(getString(R.string.cancel)).assertIsDisplayed() + onNodeWithText(getString(R.string.save)).assertIsDisplayed() + } + } + + @Test + fun testEditDeviceProfileDialog_clickCancelButton() { + var onDismissClicked = false + composeTestRule.apply { + testEditDeviceProfileDialog(onDismiss = { onDismissClicked = true }) + + // Click the "Cancel" button + onNodeWithText(getString(R.string.cancel)).performClick() + } + + // Verify onDismiss is called + Assert.assertTrue(onDismissClicked) + } + + @Test + fun testEditDeviceProfileDialog_addChannels() { + var actualDeviceProfile: DeviceProfile? = null + composeTestRule.apply { + testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it }) + + onNodeWithText(getString(R.string.save)).performClick() + } + + // Verify onConfirm is called with the correct DeviceProfile + Assert.assertEquals(deviceProfile, actualDeviceProfile) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt new file mode 100644 index 000000000..11bce1ca7 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/ScannedQrCodeDialogTest.kt @@ -0,0 +1,142 @@ +package com.geeksville.mesh.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.channelSet +import com.geeksville.mesh.channelSettings +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.ui.components.ScannedQrCodeDialog +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScannedQrCodeDialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun getString(id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + private fun getRandomKey() = Channel.getRandomKey() + + private val channels = channelSet { + settings.add(Channel.default.settings) + loraConfig = Channel.default.loraConfig + } + + private val incoming = channelSet { + settings.addAll( + listOf( + Channel.default.settings, + channelSettings { name = "2"; psk = getRandomKey() }, + channelSettings { name = "3"; psk = getRandomKey() }, + channelSettings { name = "admin"; psk = getRandomKey() }, + ) + ) + loraConfig = Channel.default.loraConfig + .copy { modemPreset = ConfigProtos.Config.LoRaConfig.ModemPreset.SHORT_FAST } + } + + private fun testScannedQrCodeDialog( + onDismiss: () -> Unit = {}, + onConfirm: (ChannelSet) -> Unit = {}, + ) = composeTestRule.setContent { + ScannedQrCodeDialog( + channels = channels, + incoming = incoming, + onDismiss = onDismiss, + onConfirm = onConfirm, + ) + } + + @Test + fun testScannedQrCodeDialog_showsDialogTitle() { + composeTestRule.apply { + testScannedQrCodeDialog() + + // Verify that the dialog title is displayed + onNodeWithText(getString(R.string.new_channel_rcvd)).assertIsDisplayed() + } + } + + @Test + fun testScannedQrCodeDialog_showsAddAndReplaceButtons() { + composeTestRule.apply { + testScannedQrCodeDialog() + + // Verify that the "Add" and "Replace" buttons are displayed + onNodeWithText(getString(R.string.add)).assertIsDisplayed() + onNodeWithText(getString(R.string.replace)).assertIsDisplayed() + } + } + + @Test + fun testScannedQrCodeDialog_showsCancelAndAcceptButtons() { + composeTestRule.apply { + testScannedQrCodeDialog() + + // Verify the "Cancel" and "Accept" buttons are displayed + onNodeWithText(getString(R.string.cancel)).assertIsDisplayed() + onNodeWithText(getString(R.string.accept)).assertIsDisplayed() + } + } + + @Test + fun testScannedQrCodeDialog_clickCancelButton() { + var onDismissClicked = false + composeTestRule.apply { + testScannedQrCodeDialog(onDismiss = { onDismissClicked = true }) + + // Click the "Cancel" button + onNodeWithText(getString(R.string.cancel)).performClick() + } + + // Verify onDismiss is called + Assert.assertTrue(onDismissClicked) + } + + @Test + fun testScannedQrCodeDialog_replaceChannels() { + var actualChannelSet: ChannelSet? = null + composeTestRule.apply { + testScannedQrCodeDialog(onConfirm = { actualChannelSet = it }) + + // Click the "Accept" button + onNodeWithText(getString(R.string.accept)).performClick() + } + + // Verify onConfirm is called with the correct ChannelSet + Assert.assertEquals(incoming, actualChannelSet) + } + + @Test + fun testScannedQrCodeDialog_addChannels() { + var actualChannelSet: ChannelSet? = null + composeTestRule.apply { + testScannedQrCodeDialog(onConfirm = { actualChannelSet = it }) + + // Click the "Add" button then the "Accept" button + onNodeWithText(getString(R.string.add)).performClick() + onNodeWithText(getString(R.string.accept)).performClick() + } + + // Verify onConfirm is called with the correct ChannelSet + val expectedChannelSet = channels.copy { + val list = LinkedHashSet(settings + incoming.settingsList) + settings.clear() + settings.addAll(list) + } + Assert.assertEquals(expectedChannelSet, actualChannelSet) + } +} diff --git a/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt new file mode 100644 index 000000000..b5ca0980e --- /dev/null +++ b/app/src/fdroid/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -0,0 +1,16 @@ +package com.geeksville.mesh + +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MeshUtilApplication : GeeksvilleApplication() { + + override fun onCreate() { + super.onCreate() + + Logging.showLogs = BuildConfig.DEBUG + + } +} \ No newline at end of file diff --git a/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt b/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt new file mode 100644 index 000000000..d4e320076 --- /dev/null +++ b/app/src/fdroid/java/com/geeksville/mesh/analytics/NopAnalytics.kt @@ -0,0 +1,52 @@ +package com.geeksville.mesh.analytics + +import android.content.Context +import com.geeksville.mesh.android.Logging + +class DataPair(val name: String, valueIn: Any?) { + val value = valueIn ?: "null" + + /// An accumulating firebase event - only one allowed per event + constructor(d: Double) : this("BOGUS", d) + constructor(d: Int) : this("BOGUS", d) +} + +/** + * Implement our analytics API using Firebase Analytics + */ +@Suppress("UNUSED_PARAMETER") +class NopAnalytics(context: Context) : AnalyticsProvider, Logging { + + init { + } + + override fun setEnabled(on: Boolean) { + } + + override fun endSession() { + } + + override fun trackLowValue(event: String, vararg properties: DataPair) { + } + + override fun track(event: String, vararg properties: DataPair) { + } + + override fun startSession() { + } + + override fun setUserInfo(vararg p: DataPair) { + } + + override fun increment(name: String, amount: Double) { + } + + /** + * Send a google analytics screen view event + */ + override fun sendScreenView(name: String) { + } + + override fun endScreenView() { + } +} diff --git a/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt new file mode 100644 index 000000000..af47b17fd --- /dev/null +++ b/app/src/fdroid/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -0,0 +1,56 @@ +package com.geeksville.mesh.android + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.provider.Settings +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import com.geeksville.mesh.analytics.AnalyticsProvider + +open class GeeksvilleApplication : Application(), Logging { + + companion object { + lateinit var analytics: AnalyticsProvider + } + + /// Are we running inside the testlab? + val isInTestLab: Boolean + get() { + val testLabSetting = + Settings.System.getString(contentResolver, "firebase.test.lab") ?: null + if(testLabSetting != null) + info("Testlab is $testLabSetting") + return "true" == testLabSetting + } + + private val analyticsPrefs: SharedPreferences by lazy { + getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE) + } + + var isAnalyticsAllowed: Boolean + get() = analyticsPrefs.getBoolean("allowed", true) + set(value) { + analyticsPrefs.edit { + putBoolean("allowed", value) + } + + // Change the flag with the providers + analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab + } + + @Suppress("UNUSED_PARAMETER") + fun askToRate(activity: AppCompatActivity) { + // do nothing + } + + override fun onCreate() { + super.onCreate() + + val nopAnalytics = com.geeksville.mesh.analytics.NopAnalytics(this) + analytics = nopAnalytics + isAnalyticsAllowed = false + } +} + +fun Context.isGooglePlayAvailable(): Boolean = false \ No newline at end of file diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt b/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt deleted file mode 100644 index 7d0daab08..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/analytics/FdroidPlatformAnalytics.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.analytics - -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import org.koin.core.annotation.Single -import org.meshtastic.app.BuildConfig -import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.PlatformAnalytics - -/** - * F-Droid specific implementation of [PlatformAnalytics]. This provides no-op implementations for analytics and other - * platform services. - */ -@Single -class FdroidPlatformAnalytics : PlatformAnalytics { - init { - // For F-Droid builds we don't initialize external analytics services. - // In debug builds we attach a DebugTree for convenient local logging, but - // release builds rely on system logging only. - if (BuildConfig.DEBUG) { - Logger.setMinSeverity(Severity.Debug) - Logger.i { "F-Droid platform no-op analytics initialized (Debug mode)." } - } else { - Logger.setMinSeverity(Severity.Info) - Logger.i { "F-Droid platform no-op analytics initialized." } - } - } - - override fun setDeviceAttributes(firmwareVersion: String, model: String) { - // No-op for F-Droid - Logger.d { "Set device attributes called: firmwareVersion=$firmwareVersion, deviceHardware=$model" } - } - - override val isPlatformServicesAvailable: Boolean - get() = false - - override fun track(event: String, vararg properties: DataPair) { - Logger.d { "Track called: event=$event, properties=${properties.toList()}" } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt deleted file mode 100644 index fba7a417f..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single -import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkFirmwareReleases -import org.meshtastic.core.network.service.ApiService - -@Module -class FDroidNetworkModule { - - @Single - fun provideApiService(): ApiService = object : ApiService { - override suspend fun getDeviceHardware(): List = - throw NotImplementedError("API calls to getDeviceHardware are not supported on Fdroid builds.") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = - throw NotImplementedError("API calls to getFirmwareReleases are not supported on Fdroid builds.") - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt deleted file mode 100644 index 5a192d437..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import org.koin.core.annotation.Module - -@Module(includes = [FDroidNetworkModule::class]) -class FlavorModule diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt b/app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt deleted file mode 100644 index a9065a24a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.intro - -import androidx.compose.runtime.Composable - -@Composable -fun AnalyticsIntro() { - // no-op for fdroid -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt deleted file mode 100644 index 21c2d4fde..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.Single -import org.meshtastic.core.ui.util.MapViewProvider - -/** OSMDroid implementation of [MapViewProvider]. */ -@Single -class FdroidMapViewProvider : MapViewProvider { - @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { - val mapViewModel: MapViewModel = koinViewModel() - LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - org.meshtastic.app.map.MapView( - modifier = modifier, - mapViewModel = mapViewModel, - navigateToNodeDetails = navigateToNodeDetails, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 48b1aa7fc..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider() diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt deleted file mode 100644 index 1243fdc8a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.content.Context -import android.util.TypedValue -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.log2 -import kotlin.math.pow - -private const val DEGREES_IN_CIRCLE = 360.0 -private const val METERS_PER_DEGREE_LATITUDE = 111320.0 -private const val ZOOM_ADJUSTMENT_FACTOR = 0.8 - -/** - * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. - * - * @return The zoom level as a Double value. - */ -fun BoundingBox.requiredZoomLevel(): Double { - val topLeft = GeoPoint(this.latNorth, this.lonWest) - val bottomRight = GeoPoint(this.latSouth, this.lonEast) - val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) - val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) - val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE)) - val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE)) - return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR -} - -/** - * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. - * - * @return A new [BoundingBox] with added [zoomFactor]. Example: - * ``` - * // Setting the zoom level directly using setZoom() - * map.setZoom(14.0) - * val boundingBoxZoom14 = map.boundingBox - * - * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) - * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) - * ``` - */ -fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { - val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) - val latDiff = latNorth - latSouth - val lonDiff = lonEast - lonWest - - val newLatDiff = latDiff / (2.0.pow(zoomFactor)) - val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) - - return BoundingBox( - center.latitude + newLatDiff / 2, - center.longitude + newLonDiff / 2, - center.latitude - newLatDiff / 2, - center.longitude - newLonDiff / 2, - ) -} - -// Converts SP to pixels. -fun Context.spToPx(sp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt() - -// Converts DP to pixels. -fun Context.dpToPx(dp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt() diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index b4d0e1bbd..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,962 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.Manifest -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.cluster.RadiusMarkerClusterer -import org.meshtastic.app.map.component.CacheLayout -import org.meshtastic.app.map.component.DownloadButton -import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.core.common.gpsDisabled -import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.calculating -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.clear -import org.meshtastic.core.resources.close -import org.meshtastic.core.resources.delete_for_everyone -import org.meshtastic.core.resources.delete_for_me -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.core.resources.location_disabled -import org.meshtastic.core.resources.map_cache_info -import org.meshtastic.core.resources.map_cache_manager -import org.meshtastic.core.resources.map_cache_size -import org.meshtastic.core.resources.map_cache_tiles -import org.meshtastic.core.resources.map_clear_tiles -import org.meshtastic.core.resources.map_download_complete -import org.meshtastic.core.resources.map_download_errors -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.resources.map_node_popup_details -import org.meshtastic.core.resources.map_offline_manager -import org.meshtastic.core.resources.map_purge_fail -import org.meshtastic.core.resources.map_purge_success -import org.meshtastic.core.resources.map_style_selection -import org.meshtastic.core.resources.map_subDescription -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.waypoint_delete -import org.meshtastic.core.resources.you -import org.meshtastic.core.ui.component.BasicListItem -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.core.ui.util.showToast -import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Waypoint -import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable -import org.osmdroid.config.Configuration -import org.osmdroid.events.MapEventsReceiver -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.cachemanager.CacheManager -import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.MapEventsOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon -import org.osmdroid.views.overlay.infowindow.InfoWindow -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay -import java.io.File -import kotlin.math.roundToInt - -private fun MapView.updateMarkers( - nodeMarkers: List, - waypointMarkers: List, - nodeClusterer: RadiusMarkerClusterer, -) { - Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } - - overlays.removeAll { overlay -> - overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) - } - - overlays.addAll(waypointMarkers) - - nodeClusterer.items.clear() - nodeClusterer.items.addAll(nodeMarkers) - nodeClusterer.invalidate() -} - -private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) = - object : CacheManager.CacheManagerCallback { - override fun onTaskComplete() { - onTaskComplete() - } - - override fun onTaskFailed(errors: Int) { - onTaskFailed(errors) - } - - override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) { - // NOOP since we are using the build in UI - } - - override fun downloadStarted() { - // NOOP since we are using the build in UI - } - - override fun setPossibleTilesInArea(total: Int) { - // NOOP since we are using the build in UI - } - } - -/** - * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user - * interactions for map manipulation, filtering, and offline caching. - * - * @param mapViewModel The [MapViewModel] providing data and state for the map. - * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. - */ -@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongMethod") -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, -) { - var mapFilterExpanded by remember { mutableStateOf(false) } - - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - - var cacheEstimate by remember { mutableStateOf("") } - - var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } - var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } - - var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } - var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } - - var showDownloadButton: Boolean by remember { mutableStateOf(false) } - var showEditWaypointDialog by remember { mutableStateOf(null) } - var showCacheManagerDialog by remember { mutableStateOf(false) } - var showCurrentCacheInfo by remember { mutableStateOf(false) } - var showPurgeTileSourceDialog by remember { mutableStateOf(false) } - var showMapStyleDialog by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - val density = LocalDensity.current - - val haptic = LocalHapticFeedback.current - fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - // Accompanist permissions state for location - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - fun loadOnlineTileSourceBase(): ITileSource { - val id = mapViewModel.mapStyleId - Logger.d { "mapStyleId from prefs: $id" } - return CustomTileSource.getTileSource(id).also { - zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false - } - } - - val initialCameraView = remember { - val nodes = mapViewModel.nodes.value - val nodesWithPosition = nodes.filter { it.validPosition != null } - val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } - BoundingBox.fromGeoPoints(geoPoints) - } - val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) - - val nodeClusterer = remember { RadiusMarkerClusterer(context) } - - fun MapView.toggleMyLocation() { - if (context.gpsDisabled()) { - Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" } - scope.launch { context.showToast(Res.string.location_disabled) } - return - } - - Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" } - if (myLocationOverlay == null) { - myLocationOverlay = - MyLocationNewOverlay(this).apply { - enableMyLocation() - enableFollowLocation() - getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let { - setPersonIcon(it) - setPersonAnchor(0.5f, 0.5f) - } - getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let { - setDirectionIcon(it) - setDirectionAnchor(0.5f, 0.5f) - } - } - overlays.add(myLocationOverlay) - } else { - myLocationOverlay?.apply { - disableMyLocation() - disableFollowLocation() - } - overlays.remove(myLocationOverlay) - myLocationOverlay = null - } - } - - // Effect to toggle MyLocation after permission is granted - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() - triggerLocationToggleAfterPermission = false - } - } - - // Keep screen on while location tracking is active - LaunchedEffect(myLocationOverlay) { - val activity = context as? android.app.Activity ?: return@LaunchedEffect - if (myLocationOverlay != null) { - activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - val myId by mapViewModel.myId.collectAsStateWithLifecycle() - - LaunchedEffect(selectedWaypointId, waypoints) { - if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { - waypoints[selectedWaypointId]?.waypoint?.let { pt -> - val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - map.controller.setCenter(geoPoint) - map.controller.setZoom(WAYPOINT_ZOOM) - } - } - } - - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - fun MapView.onNodesChanged(nodes: Collection): List { - val nodesWithPosition = nodes.filter { it.validPosition != null } - val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC - val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly - return nodesWithPosition.mapNotNull { node -> - if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { - return@mapNotNull null - } - if ( - mapFilterStateValue.lastHeardFilter.seconds != 0L && - (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && - node.num != ourNode?.num - ) { - return@mapNotNull null - } - - val (p, u) = node.position to node.user - val nodePosition = GeoPoint(node.latitude, node.longitude) - MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply { - id = u.id - title = u.long_name - snippet = - getString( - Res.string.map_node_popup_details, - node.gpsString(), - formatAgo(node.lastHeard), - formatAgo(p.time), - if (node.batteryStr != "") node.batteryStr else "?", - ) - ourNode?.distanceStr(node, displayUnits)?.let { dist -> - ourNode.bearing(node)?.let { bearing -> - subDescription = getString(Res.string.map_subDescription, bearing, dist) - } - } - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - position = nodePosition - icon = markerIcon - setNodeColors(node.colors) - if (!mapFilterStateValue.showPrecisionCircle) { - setPrecisionBits(0) - } else { - setPrecisionBits(p.precision_bits) - } - setOnLongClickListener { - navigateToNodeDetails(node.num) - true - } - } - } - } - - fun showDeleteMarkerDialog(waypoint: Waypoint) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(getString(Res.string.waypoint_delete)) - builder.setNeutralButton(getString(Res.string.cancel)) { _, _ -> - Logger.d { "User canceled marker delete dialog" } - } - builder.setNegativeButton(getString(Res.string.delete_for_me)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for me" } - mapViewModel.deleteWaypoint(waypoint.id) - } - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) - mapViewModel.deleteWaypoint(waypoint.id) - } - } - val dialog = builder.show() - for ( - button in - setOf( - androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, - androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, - androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE, - ) - ) { - with(dialog.getButton(button)) { - textSize = 12F - isAllCaps = false - } - } - } - - fun showMarkerLongPressDialog(id: Int) { - performHapticFeedback() - Logger.d { "marker long pressed id=$id" } - val waypoint = waypoints[id]?.waypoint ?: return - // edit only when unlocked or lockedTo myNodeNum - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - showEditWaypointDialog = waypoint - } else { - showDeleteMarkerDialog(waypoint) - } - } - - fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { - getString(Res.string.you) - } else { - mapViewModel.getUser(id).long_name - } - - @Suppress("MagicNumber") - fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { - return waypoints.mapNotNull { waypoint -> - val pt = waypoint.waypoint ?: return@mapNotNull null - if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState - val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else "" - val time = DateFormatter.formatDateTime(waypoint.time) - val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt()) - val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) - val now = nowMillis - val expireTimeMillis = pt.expire * 1000L - val expireTimeStr = - when { - pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never" - expireTimeMillis <= now -> "Expired" - else -> DateFormatter.formatRelativeTime(expireTimeMillis) - } - MarkerWithLabel(this, label, emoji).apply { - id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.from)}$lock)" - snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr" - position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - if (selectedWaypointId == pt.id) { - showInfoWindow() - } - setOnLongClickListener { - showMarkerLongPressDialog(pt.id) - true - } - } - } - } - - val mapEventsReceiver = - object : MapEventsReceiver { - override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { - InfoWindow.closeAllInfoWindowsOn(map) - return true - } - - override fun longPressHelper(p: GeoPoint): Boolean { - performHapticFeedback() - val enabled = isConnected && downloadRegionBoundingBox == null - - if (enabled) { - showEditWaypointDialog = - Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) - } - return true - } - } - - fun MapView.drawOverlays() { - if (overlays.none { it is MapEventsOverlay }) { - overlays.add(0, MapEventsOverlay(mapEventsReceiver)) - } - if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { - overlays.add(myLocationOverlay) - } - if (overlays.none { it is RadiusMarkerClusterer }) { - overlays.add(nodeClusterer) - } - - addCopyright() - addScaleBarOverlay(density) - createLatLongGrid(false) - - invalidate() - } - - fun MapView.generateBoxOverlay() { - overlays.removeAll { it is Polygon } - val zoomFactor = 1.3 - zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) - downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) - val polygon = - Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } - } - overlays.add(polygon) - invalidate() - val tileCount: Int = - CacheManager(this) - .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) - cacheEstimate = getString(Res.string.map_cache_tiles, tileCount) - } - - val boxOverlayListener = - object : MapListener { - override fun onScroll(event: ScrollEvent): Boolean { - if (downloadRegionBoundingBox != null) { - event.source.generateBoxOverlay() - } - return true - } - - override fun onZoom(event: ZoomEvent): Boolean = false - } - - fun startDownload() { - val boundingBox = downloadRegionBoundingBox ?: return - try { - val outputName = buildString { - append(Configuration.getInstance().osmdroidBasePath.absolutePath) - append(File.separator) - append("mainFile.sqlite") - } - val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) - cacheManager.downloadAreaAsync( - context, - boundingBox, - zoomLevelMin.toInt(), - zoomLevelMax.toInt(), - cacheManagerCallback( - onTaskComplete = { - scope.launch { context.showToast(Res.string.map_download_complete) } - writer.onDetach() - }, - onTaskFailed = { errors -> - scope.launch { context.showToast(Res.string.map_download_errors, errors) } - writer.onDetach() - }, - ), - ) - } catch (ex: TileSourcePolicyException) { - Logger.d { "Tile source does not allow archiving: ${ex.message}" } - } catch (ex: Exception) { - Logger.d { "Tile source exception: ${ex.message}" } - } - } - - Scaffold( - modifier = modifier, - floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - AndroidView( - factory = { - map.apply { - setDestroyMode(false) - addMapListener(boxOverlayListener) - } - }, - modifier = Modifier.fillMaxSize(), - update = { mapView -> - with(mapView) { - updateMarkers( - onNodesChanged(nodes), - onWaypointChanged(waypoints.values, selectedWaypointId), - nodeClusterer, - ) - } - mapView.drawOverlays() - }, // Renamed map to mapView to avoid conflict - ) - if (downloadRegionBoundingBox != null) { - CacheLayout( - cacheEstimate = cacheEstimate, - onExecuteJob = { startDownload() }, - onCancelDownload = { - downloadRegionBoundingBox = null - map.overlays.removeAll { it is Polygon } - map.invalidate() - }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterExpanded = true }, - filterDropdownContent = { - FdroidMainMapFilterDropdown( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - mapFilterState = mapFilterState, - mapViewModel = mapViewModel, - ) - }, - mapTypeContent = { - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.map_style_selection), - onClick = { showMapStyleDialog = true }, - ) - }, - isLocationTrackingEnabled = myLocationOverlay != null, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - ) - } - } - } - - if (showMapStyleDialog) { - MapStyleDialog( - selectedMapStyle = mapViewModel.mapStyleId, - onDismiss = { showMapStyleDialog = false }, - onSelectMapStyle = { - mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) - }, - ) - } - - if (showCacheManagerDialog) { - CacheManagerDialog( - onClickOption = { option -> - when (option) { - CacheManagerOption.CurrentCacheSize -> { - scope.launch { context.showToast(Res.string.calculating) } - showCurrentCacheInfo = true - } - CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() - - CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true - CacheManagerOption.Cancel -> Unit - } - showCacheManagerDialog = false - }, - onDismiss = { showCacheManagerDialog = false }, - ) - } - - if (showCurrentCacheInfo) { - CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) - } - - if (showPurgeTileSourceDialog) { - PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) - } - - if (showEditWaypointDialog != null) { - EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, // Safe call - onSendClicked = { waypoint -> - Logger.d { "User clicked send waypoint ${waypoint.id}" } - showEditWaypointDialog = null - - val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id - val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name - val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire - val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0 - val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon - - mapViewModel.sendWaypoint( - waypoint.copy( - id = newId, - name = newName, - expire = newExpire, - locked_to = newLockedTo, - icon = newIcon, - ), - ) - }, - onDeleteClicked = { waypoint -> - Logger.d { "User clicked delete waypoint ${waypoint.id}" } - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Logger.d { "User clicked cancel marker edit dialog" } - showEditWaypointDialog = null - }, - ) - } -} - -/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */ -@Composable -private fun FdroidMainMapFilterDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - mapFilterState: MapFilterState, - mapViewModel: MapViewModel, -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f)) - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - HorizontalDivider() - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} - -@Composable -private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { - val selected = remember { mutableStateOf(selectedMapStyle) } - - MapsDialog(onDismiss = onDismiss) { - CustomTileSource.mTileSources.values.forEachIndexed { index, style -> - ListItem( - text = style, - trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, - onClick = { - selected.value = index - onSelectMapStyle(index) - onDismiss() - }, - ) - } - } -} - -private enum class CacheManagerOption(val label: StringResource) { - CurrentCacheSize(label = Res.string.map_cache_size), - DownloadRegion(label = Res.string.map_download_region), - ClearTiles(label = Res.string.map_clear_tiles), - Cancel(label = Res.string.cancel), -} - -@Composable -private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { - MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { - CacheManagerOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickOption(option) - onDismiss() - } - } - } -} - -@Composable -private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { - val (cacheCapacity, currentCacheUsage) = - remember(mapView) { - val cacheManager = CacheManager(mapView) - cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() - } - - MapsDialog( - title = stringResource(Res.string.map_cache_manager), - onDismiss = onDismiss, - negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, - ) { - val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() - val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() - Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) - } -} - -@Composable -private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val cache = SqlTileWriterExt() - - val sourceList by derivedStateOf { cache.sources.map { it.source as String } } - - val selected = remember { mutableStateListOf() } - - MapsDialog( - title = stringResource(Res.string.map_tile_source), - positiveButton = { - TextButton( - enabled = selected.isNotEmpty(), - onClick = { - selected.forEach { selectedIndex -> - val source = sourceList[selectedIndex] - scope.launch { - context.showToast( - if (cache.purgeCache(source)) { - getString(Res.string.map_purge_success, source) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } - - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) - } - }, - negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, - onDismiss = onDismiss, - ) { - sourceList.forEachIndexed { index, source -> - val isSelected = selected.contains(index) - BasicListItem( - text = source, - trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, - onClick = { - if (isSelected) { - selected.remove(index) - } else { - selected.add(index) - } - }, - ) {} - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MapsDialog( - title: String? = null, - onDismiss: () -> Unit, - positiveButton: (@Composable () -> Unit)? = null, - negativeButton: (@Composable () -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.wrapContentWidth().wrapContentHeight(), - shape = MaterialTheme.shapes.large, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column { - title?.let { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } - } - } - } -} - -private const val WAYPOINT_ZOOM = 15.0 diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt deleted file mode 100644 index 3cc0dbaf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import android.graphics.Typeface -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import org.meshtastic.app.R -import org.meshtastic.proto.Position -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.CopyrightOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import org.osmdroid.views.overlay.ScaleBarOverlay -import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList -import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 - -/** Adds copyright to map depending on what source is showing */ -fun MapView.addCopyright() { - if (overlays.none { it is CopyrightOverlay }) { - val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return - val copyrightOverlay = CopyrightOverlay(context) - copyrightOverlay.setCopyrightNotice(copyrightNotice) - overlays.add(copyrightOverlay) - } -} - -/** - * Create LatLong Grid line overlay - * - * @param enabled: turn on/off gridlines - */ -fun MapView.createLatLongGrid(enabled: Boolean) { - val latLongGridOverlay = LatLonGridlineOverlay2() - latLongGridOverlay.isEnabled = enabled - if (latLongGridOverlay.isEnabled) { - val textPaint = - Paint().apply { - textSize = 40f - color = Color.GRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - latLongGridOverlay.textPaint = textPaint - latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT) - latLongGridOverlay.setLineWidth(3.0f) - latLongGridOverlay.setLineColor(Color.GRAY) - overlays.add(latLongGridOverlay) - } -} - -fun MapView.addScaleBarOverlay(density: Density) { - if (overlays.none { it is ScaleBarOverlay }) { - val scaleBarOverlay = - ScaleBarOverlay(this).apply { - setAlignBottom(true) - with(density) { - setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt()) - setTextSize(12.sp.toPx()) - } - textPaint.apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } - } - overlays.add(scaleBarOverlay) - } -} - -fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () -> Unit): Polyline { - val polyline = - Polyline(this).apply { - val borderPaint = - Paint().apply { - color = Color.BLACK - isAntiAlias = true - strokeWidth = with(density) { 10.dp.toPx() } - style = Paint.Style.STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(borderPaint)) - val fillPaint = - Paint().apply { - color = Color.WHITE - isAntiAlias = true - strokeWidth = with(density) { 6.dp.toPx() } - style = Paint.Style.FILL_AND_STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(fillPaint)) - setPoints(geoPoints) - setOnClickListener { _, _, _ -> - onClick() - true - } - } - overlays.add(polyline) - - return polyline -} - -fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { - val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = - positions.map { pos -> - Marker(this).apply { - icon = navIcon - rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick(pos.time) - true - } - } - } - overlays.addAll(markers) - - return markers -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index 1ffe68aa1..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.LocalConfig - -@Suppress("LongParameterList") -@KoinViewModel -class MapViewModel( - mapPrefs: MapPrefs, - packetRepository: PacketRepository, - nodeRepository: NodeRepository, - radioController: RadioController, - radioConfigRepository: RadioConfigRepository, - buildConfigProvider: BuildConfigProvider, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - } - } - - var mapStyleId: Int - get() = mapPrefs.mapStyle.value - set(value) { - mapPrefs.setMapStyle(value) - } - - val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - - val config - get() = localConfig.value - - val applicationId = buildConfigProvider.applicationId -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt deleted file mode 100644 index 112449d1f..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.database.Cursor -import org.meshtastic.core.common.util.nowMillis -import org.osmdroid.tileprovider.modules.DatabaseFileArchive -import org.osmdroid.tileprovider.modules.SqlTileWriter - -/** - * Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need - * to put these with the osmdroid-android library, thus they were put here as more of an example. - * - * created on 12/21/2016. - * - * @author Alex O'Ree - * @since 5.6.2 - */ -class SqlTileWriterExt : SqlTileWriter() { - fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_KEY + - "," + - COLUMN_EXPIRES + - "," + - DatabaseFileArchive.COLUMN_PROVIDER + - " from " + - DatabaseFileArchive.TABLE + - " limit ? offset ?", - arrayOf(rows.toString() + "", offset.toString() + ""), - ) - - /** - * gets all the tiles sources that we have tiles for in the cache database and their counts - * - * @return - */ - val sources: List - get() { - val db = db - val ret: MutableList = ArrayList() - if (db == null) { - return ret - } - var cur: Cursor? = null - try { - cur = - db.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_PROVIDER + - ",count(*) " + - ",min(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",max(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",sum(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - "from " + - DatabaseFileArchive.TABLE + - " " + - "group by " + - DatabaseFileArchive.COLUMN_PROVIDER, - null, - ) - while (cur.moveToNext()) { - val c = SourceCount() - c.source = cur.getString(0) - c.rowCount = cur.getLong(1) - c.sizeMin = cur.getLong(2) - c.sizeMax = cur.getLong(3) - c.sizeTotal = cur.getLong(4) - c.sizeAvg = c.sizeTotal / c.rowCount - ret.add(c) - } - } catch (e: Exception) { - catchException(e) - } finally { - cur?.close() - } - return ret - } - - val rowCountExpired: Long - get() = getRowCount("$COLUMN_EXPIRES. - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.map_select_download_region -import org.meshtastic.core.resources.map_start_download -import org.meshtastic.core.resources.map_tile_download_estimate - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun CacheLayout( - cacheEstimate: String, - onExecuteJob: () -> Unit, - onCancelDownload: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight() - .background(color = MaterialTheme.colorScheme.background) - .padding(8.dp), - ) { - Text( - text = stringResource(Res.string.map_select_download_region), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(space = 8.dp), - ) { - Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary) - } - Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun CacheLayoutPreview() { - CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {}) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt deleted file mode 100644 index 7568d695a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.ui.icon.Download -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { - AnimatedVisibility( - visible = enabled, - enter = - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - exit = - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - ) { - FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { - Icon( - imageVector = MeshtasticIcons.Download, - contentDescription = stringResource(Res.string.map_download_region), - modifier = Modifier.scale(1.25f), - ) - } - } -} - -// @Preview(showBackground = true) -// @Composable -// private fun DownloadButtonPreview() { -// DownloadButton(true, onClick = {}) -// } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index c41798bf0..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import android.app.DatePickerDialog -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.Month -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours -import kotlin.time.Instant - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun EditWaypointDialog( - waypoint: Waypoint, - onSendClicked: (Waypoint) -> Unit, - onDeleteClicked: (Waypoint) -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - var waypointInput by remember { mutableStateOf(waypoint) } - val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit - - @Suppress("MagicNumber") - val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon - var showEmojiPickerView by remember { mutableStateOf(false) } - - // Get current context for dialogs - val context = LocalContext.current - val tz = systemTimeZone - - // Determine locale-specific date format - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - // Check if 24-hour format is preferred - val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - - val currentInstant = - remember(waypointInput.expire) { - val expire = waypointInput.expire - if (expire != 0 && expire != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(expire.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - - // State to hold selected date and time - var selectedDate by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - var selectedTime by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - shape = RoundedCornerShape(16.dp), - text = { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = stringResource(title), - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - ) - EditTextPreference( - title = stringResource(Res.string.name), - value = waypointInput.name, - maxSize = 29, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(name = it) }, - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(emoji)), - modifier = - Modifier.background(MaterialTheme.colorScheme.background, CircleShape) - .padding(4.dp), - fontSize = 24.sp, - color = Color.Unspecified.copy(alpha = 1f), - ) - } - }, - ) - EditTextPreference( - title = stringResource(Res.string.description), - value = waypointInput.description, - maxSize = 99, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(description = it) }, - ) - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Text(stringResource(Res.string.locked)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.locked_to != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - - val ldt = currentInstant.toLocalDateTime(tz) - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val newLdt = - LocalDateTime( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - hour = ldt.hour, - minute = ldt.minute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.ordinal, - ldt.day, - ) - - val timePickerDialog = - android.app.TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val newLdt = - LocalDateTime( - year = ldt.year, - month = ldt.month, - day = ldt.day, - hour = selectedHour, - minute = selectedMinute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - is24Hour, - ) - - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Text(stringResource(Res.string.expires)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0, - onCheckedChange = { isChecked -> - if (isChecked) { - waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt()) - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDate, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTime, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - } - } - } - }, - confirmButton = { - FlowRow( - modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.Center, - ) { - TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } - if (waypoint.id != 0) { - Button( - modifier = modifier.weight(1f), - onClick = { onDeleteClicked(waypointInput) }, - enabled = !(waypointInput.name.isNullOrEmpty()), - ) { - Text(stringResource(Res.string.delete)) - } - } - Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) { - Text(stringResource(Res.string.send)) - } - } - }, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = it.codePointAt(0)) - } - } -} - -@Preview(showBackground = true) -@Composable -@Suppress("MagicNumber") -private fun EditWaypointFormPreview() { - AppTheme { - EditWaypointDialog( - waypoint = - Waypoint( - id = 123, - name = "Test 123", - description = "This is only a test", - icon = 128169, - expire = (nowSeconds.toInt() + 8 * 3600), - ), - onSendClicked = {}, - onDeleteClicked = {}, - onDismissRequest = {}, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index de0f8c6c2..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("UnusedPrivateProperty") -class CustomTileSource { - - companion object { - val OPENWEATHER_RADAR = - OnlineTileSourceAuth( - "Open Weather Map", - 1, - 22, - 256, - ".png", - arrayOf("https://tile.openweathermap.org/map/"), - "Openweathermap", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - "precipitation", - "", - ) - private val ESRI_IMAGERY = - object : - OnlineTileSourceBase( - "ESRI World Overview", - 1, - 20, - 256, - ".jpg", - arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"), - "Esri, Maxar, Earthstar Geographics, and the GIS User Community", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - private val ESRI_WORLD_TOPO = - object : - OnlineTileSourceBase( - "ESRI World TOPO", - 1, - 20, - 256, - ".jpg", - arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"), - "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_HYDRO_CACHE = - object : - OnlineTileSourceBase( - "USGS Hydro Cache", - 0, - 18, - 256, - "", - arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - private val USGS_SHADED_RELIEF = - object : - OnlineTileSourceBase( - "USGS Shaded Relief Only", - 0, - 18, - 256, - "", - arrayOf( - "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/", - ), - "USGS", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - /** WMS TILE SERVER More research is required to get this to function correctly with overlays */ - val NOAA_RADAR_WMS = - NOAAWmsTileSource( - "Recent Weather Radar", - arrayOf( - "https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" + - "radar_meteo_imagery_nexrad_time/MapServer/WmsServer?", - ), - "1", - "1.1.0", - "", - "EPSG%3A3857", - "", - "image/png", - ) - - /** =============================================================================================== */ - private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK - private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO - private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo - private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT - private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP - val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE - - /** Source for each available [ITileSource] and their display names. */ - val mTileSources: Map = - mapOf( - MAPNIK to "OpenStreetMap", - USGS_TOPO to "USGS TOPO", - OPEN_TOPO to "Open TOPO", - ESRI_WORLD_TOPO to "ESRI World TOPO", - USGS_SAT to "USGS Satellite", - ESRI_IMAGERY to "ESRI World Overview", - ) - - fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE - - fun getTileSource(aName: String): ITileSource { - for (tileSource: ITileSource in mTileSources.keys) { - if (tileSource.name().equals(aName)) { - return tileSource - } - } - throw IllegalArgumentException("No such tile source: $aName") - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt deleted file mode 100644 index da94a7725..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.MotionEvent -import org.meshtastic.app.map.dpToPx -import org.meshtastic.app.map.spToPx -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon - -class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) { - - companion object { - private const val LABEL_CORNER_RADIUS_DP = 4f - private const val LABEL_Y_OFFSET_DP = 34f - private const val FONT_SIZE_SP = 14f - private const val EMOJI_FONT_SIZE_SP = 20f - } - - private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 } - - private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 } - - private var nodeColor: Int = Color.GRAY - - fun setNodeColors(colors: Pair) { - nodeColor = colors.second - } - - private var precisionBits: Int? = null - - fun setPrecisionBits(bits: Int) { - precisionBits = bits - } - - @Suppress("MagicNumber") - private fun getPrecisionMeters(): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.53562 - 16 -> 364.7622 - 17 -> 182.375556 - 18 -> 91.182212 - 19 -> 45.58554 - else -> null - } - - private var onLongClickListener: (() -> Boolean)? = null - - fun setOnLongClickListener(listener: () -> Boolean) { - onLongClickListener = listener - } - - private val mLabel = label - private val mEmoji = emoji - private val textPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f - color = Color.DKGRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - private val emojiPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - private val bgPaint = Paint().apply { color = Color.WHITE } - - private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF { - val fontMetrics = textPaint.fontMetrics - val halfTextLength = textPaint.measureText(text) / 2 + 3 - return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom)) - } - - override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { - val touched = hitTest(event, mapView) - if (touched && this.id != null) { - return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView) - } - return super.onLongPress(event, mapView) - } - - @Suppress("MagicNumber") - override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { - super.draw(c, osmv, false) - val p = mPositionPixels - val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat())) - bgRect.inset(-8F, -2F) - - if (mLabel.isNotEmpty()) { - c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint) - c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint) - } - mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } - - getPrecisionMeters()?.let { radius -> - val polygon = - Polygon(osmv).apply { - points = Polygon.pointsAsCircle(position, radius) - fillPaint.apply { - color = nodeColor - alpha = 48 - } - outlinePaint.apply { - color = nodeColor - alpha = 64 - } - } - polygon.draw(c, osmv, false) - } - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt deleted file mode 100644 index 3d51133bd..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("LongParameterList") -open class OnlineTileSourceAuth( - name: String, - zoomLevel: Int, - zoomMaxLevel: Int, - tileSizePixels: Int, - imageFileNameEnding: String, - baseUrl: Array, - pCopyright: String, - tileSourcePolicy: TileSourcePolicy, - layerName: String?, - apiKey: String, -) : OnlineTileSourceBase( - name, - zoomLevel, - zoomMaxLevel, - tileSizePixels, - imageFileNameEnding, - baseUrl, - pCopyright, - tileSourcePolicy, -) { - private var layerName = "" - private var apiKey = "" - - init { - if (layerName != null) { - this.layerName = layerName - } - this.apiKey = apiKey - } - - override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getX(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex).toString() - ) + - mImageFilenameEnding + - "?appId=$apiKey" -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt deleted file mode 100644 index b7795180f..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.node.NodeMapViewModel - -@Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - - Scaffold( - topBar = { - MainAppBar( - title = node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = {}, - onClickChip = {}, - ) - }, - ) { paddingValues -> - NodeTrackOsmMap( - positions = positions, - applicationId = nodeMapViewModel.applicationId, - mapStyleId = nodeMapViewModel.mapStyleId, - modifier = Modifier.fillMaxSize().padding(paddingValues), - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 77b595d88..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain - * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation - * ([NodeTrackOsmMap]). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - NodeTrackOsmMap( - positions = positions, - applicationId = vm.applicationId, - mapStyleId = vm.mapStyleId, - modifier = modifier, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt deleted file mode 100644 index a6aec4c2d..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addPolyline -import org.meshtastic.app.map.addPositionMarkers -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.roundToInt - -/** - * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional - * markers for each historical position. - * - * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] - * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a - * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider - * so users can adjust the time range directly from the map. - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - * - * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or - * location tracking. It is designed to be embedded inside the position-log adaptive layout. - */ -@Composable -fun NodeTrackOsmMap( - positions: List, - applicationId: String, - mapStyleId: Int, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, - mapViewModel: MapViewModel = koinViewModel(), -) { - val density = LocalDensity.current - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - - val filteredPositions = - remember(positions, lastHeardTrackFilter) { - positions.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - } - - val geoPoints = - remember(filteredPositions) { - filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } - } - val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = applicationId, - box = cameraView, - tileSource = CustomTileSource.getTileSource(mapStyleId), - ) - - var filterMenuExpanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - AndroidView( - modifier = Modifier.matchParentSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) } - // Center on selected position - if (selectedPositionTime != null) { - val selected = filteredPositions.find { it.time == selectedPositionTime } - if (selected != null) { - val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) - map.controller.animateTo(point) - } - } - }, - ) - - // Track filter controls overlay - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { filterMenuExpanded = true }, - filterDropdownContent = { - DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(lastHeardTrackFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } - }, - ) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index fcf1d47e9..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.traceroute - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation - * ([TracerouteOsmMap]). - */ -@Composable -fun TracerouteMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, -) { - TracerouteOsmMap( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - onMappableCountChanged = onMappableCountChanged, - modifier = modifier, - ) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt deleted file mode 100644 index 55b49154a..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map.traceroute - -import android.graphics.Paint -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.app.map.rememberMapViewWithLifecycle -import org.meshtastic.app.map.zoomIn -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS -import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Position -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin - -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 -private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 - -/** - * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and - * forward/return offset polylines with auto-centering camera. - * - * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any - * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold. - */ -@Composable -fun TracerouteOsmMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), -) { - val context = LocalContext.current - val density = LocalDensity.current - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } - - // Resolve which nodes to display for the traceroute - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, nodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = nodes, - ) - } - val displayNodes = tracerouteSelection.nodesForMarkers - val nodeLookup = tracerouteSelection.nodeLookup - - // Report mappable count - LaunchedEffect(tracerouteOverlay, displayNodes) { - if (tracerouteOverlay != null) { - onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) - } - } - - // Compute polyline GeoPoints from node positions - val forwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - val returnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - - // Compute offset polylines for visual separation - val headingReferencePoints = - remember(forwardPoints, returnPoints) { - when { - forwardPoints.size >= 2 -> forwardPoints - returnPoints.size >= 2 -> returnPoints - else -> emptyList() - } - } - val forwardOffsetPoints = - remember(forwardPoints, headingReferencePoints) { - offsetPolyline( - points = forwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = 1.0, - ) - } - val returnOffsetPoints = - remember(returnPoints, headingReferencePoints) { - offsetPolyline( - points = returnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = headingReferencePoints, - sideMultiplier = -1.0, - ) - } - - // Camera auto-center - var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) } - - // Build initial camera from all traceroute points - val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() } - val initialCameraView = - remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) } - - val mapView = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView ?: BoundingBox(), - tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId), - ) - - // Center camera on traceroute bounds - LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) { - if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect - if (allPoints.isNotEmpty()) { - if (allPoints.size == 1) { - mapView.controller.setCenter(allPoints.first()) - mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) - } else { - mapView.zoomToBoundingBox( - BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), - true, - ) - } - hasCentered = true - } - } - - AndroidView( - modifier = modifier, - factory = { mapView.apply { setDestroyMode(false) } }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - - // Render traceroute polylines - buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) } - - // Render simple node markers - displayNodes.forEach { node -> - val position = GeoPoint(node.latitude, node.longitude) - val marker = - MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}") - .apply { - id = node.user.id - title = node.user.long_name - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - this.position = position - icon = markerIcon - setNodeColors(node.colors) - } - map.overlays.add(marker) - } - - map.invalidate() - }, - ) -} - -private fun buildTraceroutePolylines( - forwardPoints: List, - returnPoints: List, - density: androidx.compose.ui.unit.Density, -): List { - val polylines = mutableListOf() - - fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { - setPoints(points) - outlinePaint.apply { - this.color = color - this.strokeWidth = strokeWidth - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - style = Paint.Style.STROKE - } - } - - forwardPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() })) - } - returnPoints - .takeIf { it.size >= 2 } - ?.let { points -> - polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() })) - } - return polylines -} - -// --- Haversine offset math for OSMDroid (no SphericalUtil available) --- - -private fun Double.toRad(): Double = this * PI / 180.0 - -private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { - val lat1 = from.latitude.toRad() - val lat2 = to.latitude.toRad() - val dLon = (to.longitude - from.longitude).toRad() - return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) -} - -private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { - val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS - val lat1 = latitude.toRad() - val lon1 = longitude.toRad() - val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) - val lon2 = - lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) - return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI) -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> bearingRad(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) - - else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (PI / 2 * sideMultiplier) - point.offsetPoint(perpendicularHeading, abs(offsetMeters)) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index 447765522..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.component - -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import org.meshtastic.core.model.Node -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker - -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val context = androidx.compose.ui.platform.LocalContext.current - - val map = remember { - MapView(context).apply { - layoutParams = - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - // Default osmdroid tile source. - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(false) - - controller.setZoom(15.0) - } - } - - LaunchedEffect(node.num) { - val point = GeoPoint(node.latitude, node.longitude) - - map.overlays.clear() - - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - } - map.overlays.add(marker) - - map.controller.animateTo(point) - } - - AndroidView(factory = { map }, modifier = modifier) -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index d6515eeb7..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomEnd, - overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp), - contentHorizontalAlignment = Alignment.End, -) diff --git a/app/src/fdroid/res/drawable/ic_location_on.xml b/app/src/fdroid/res/drawable/ic_location_on.xml deleted file mode 100644 index b93f33174..000000000 --- a/app/src/fdroid/res/drawable/ic_location_on.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/fdroid/res/drawable/ic_map_location_dot.xml b/app/src/fdroid/res/drawable/ic_map_location_dot.xml deleted file mode 100644 index 2935f162e..000000000 --- a/app/src/fdroid/res/drawable/ic_map_location_dot.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/fdroid/res/drawable/ic_map_navigation.xml b/app/src/fdroid/res/drawable/ic_map_navigation.xml deleted file mode 100644 index 83d579f8a..000000000 --- a/app/src/fdroid/res/drawable/ic_map_navigation.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/fdroidDebug/res/drawable-anydpi/ic_launcher_background.xml b/app/src/fdroidDebug/res/drawable-anydpi/ic_launcher_background.xml deleted file mode 100644 index 170282ec9..000000000 --- a/app/src/fdroidDebug/res/drawable-anydpi/ic_launcher_background.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/app/src/fdroidDebug/res/values/strings.xml b/app/src/fdroidDebug/res/values/strings.xml deleted file mode 100644 index 2571a435c..000000000 --- a/app/src/fdroidDebug/res/values/strings.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - Fdroid Debug - diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml deleted file mode 100644 index c4138cb0b..000000000 --- a/app/src/google/AndroidManifest.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt new file mode 100644 index 000000000..8fa7c2c9d --- /dev/null +++ b/app/src/google/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -0,0 +1,55 @@ +package com.geeksville.mesh + +import android.os.Debug +import com.geeksville.mesh.android.AppPrefs +import com.geeksville.mesh.android.BuildUtils.isEmulator +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.util.Exceptions +import com.google.firebase.crashlytics.crashlytics +import com.google.firebase.Firebase +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MeshUtilApplication : GeeksvilleApplication() { + + override fun onCreate() { + super.onCreate() + + Logging.showLogs = BuildConfig.DEBUG + + // We default to off in the manifest - we turn on here if the user approves + // leave off when running in the debugger + if (!isEmulator && (!BuildConfig.DEBUG || !Debug.isDebuggerConnected())) { + val crashlytics = Firebase.crashlytics + crashlytics.setCrashlyticsCollectionEnabled(isAnalyticsAllowed) + crashlytics.setCustomKey("debug_build", BuildConfig.DEBUG) + + val pref = AppPrefs(this) + crashlytics.setUserId(pref.getInstallId()) // be able to group all bugs per anonymous user + + // We always send our log messages to the crashlytics lib, but they only get sent to the server if we report an exception + // This makes log messages work properly if someone turns on analytics just before they click report bug. + // send all log messages through crashyltics, so if we do crash we'll have those in the report + val standardLogger = Logging.printlog + Logging.printlog = { level, tag, message -> + crashlytics.log("$tag: $message") + standardLogger(level, tag, message) + } + + fun sendCrashReports() { + if (isAnalyticsAllowed) + crashlytics.sendUnsentReports() + } + + // Send any old reports if user approves + sendCrashReports() + + // Attach to our exception wrapper + Exceptions.reporter = { exception, _, _ -> + crashlytics.recordException(exception) + sendCrashReports() // Send the new report + } + } + } +} diff --git a/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt b/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt new file mode 100644 index 000000000..4d2246e4a --- /dev/null +++ b/app/src/google/java/com/geeksville/mesh/analytics/FirebaseAnalytics.kt @@ -0,0 +1,88 @@ +package com.geeksville.mesh.analytics + +import android.content.Context +import android.os.Bundle +import com.geeksville.mesh.android.AppPrefs +import com.geeksville.mesh.android.Logging +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.analytics +import com.google.firebase.analytics.logEvent +import com.google.firebase.Firebase + +class DataPair(val name: String, valueIn: Any?) { + val value = valueIn ?: "null" + + /// An accumulating firebase event - only one allowed per event + constructor(d: Double) : this(FirebaseAnalytics.Param.VALUE, d) + constructor(d: Int) : this(FirebaseAnalytics.Param.VALUE, d) +} + +/** + * Implement our analytics API using Firebase Analytics + */ +class FirebaseAnalytics(context: Context) : AnalyticsProvider, Logging { + + val t = Firebase.analytics + + init { + val pref = AppPrefs(context) + t.setUserId(pref.getInstallId()) + } + + override fun setEnabled(on: Boolean) { + t.setAnalyticsCollectionEnabled(on) + } + + override fun endSession() { + track("End Session") + // Mint.flush() // Send results now + } + + override fun trackLowValue(event: String, vararg properties: DataPair) { + track(event, *properties) + } + + override fun track(event: String, vararg properties: DataPair) { + debug("Analytics: track $event") + + val bundle = Bundle() + properties.forEach { + when (it.value) { + is Double -> bundle.putDouble(it.name, it.value) + is Int -> bundle.putLong(it.name, it.value.toLong()) + is Long -> bundle.putLong(it.name, it.value) + is Float -> bundle.putDouble(it.name, it.value.toDouble()) + else -> bundle.putString(it.name, it.value.toString()) + } + } + t.logEvent(event, bundle) + } + + override fun startSession() { + debug("Analytics: start session") + // automatic with firebase + } + + override fun setUserInfo(vararg p: DataPair) { + p.forEach { t.setUserProperty(it.name, it.value.toString()) } + } + + override fun increment(name: String, amount: Double) { + //Mint.logEvent("$name increment") + } + + /** + * Send a google analytics screen view event + */ + override fun sendScreenView(name: String) { + debug("Analytics: start screen $name") + t.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { + param(FirebaseAnalytics.Param.SCREEN_NAME, name) + param(FirebaseAnalytics.Param.SCREEN_CLASS, "MainActivity") + } + } + + override fun endScreenView() { + // debug("Analytics: end screen") + } +} diff --git a/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt new file mode 100644 index 000000000..d391bdf77 --- /dev/null +++ b/app/src/google/java/com/geeksville/mesh/android/GeeksvilleApplication.kt @@ -0,0 +1,82 @@ +package com.geeksville.mesh.android + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.provider.Settings +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import com.geeksville.mesh.analytics.AnalyticsProvider +import com.geeksville.mesh.util.exceptionReporter +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailabilityLight +import com.suddenh4x.ratingdialog.AppRating + +/** + * Created by kevinh on 1/4/15. + */ + +open class GeeksvilleApplication : Application(), Logging { + + companion object { + lateinit var analytics: AnalyticsProvider + } + + /// Are we running inside the testlab? + val isInTestLab: Boolean + get() { + val testLabSetting = + Settings.System.getString(contentResolver, "firebase.test.lab") ?: null + if(testLabSetting != null) + info("Testlab is $testLabSetting") + return "true" == testLabSetting + } + + private val analyticsPrefs: SharedPreferences by lazy { + getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE) + } + + var isAnalyticsAllowed: Boolean + get() = analyticsPrefs.getBoolean("allowed", true) + set(value) { + analyticsPrefs.edit { + putBoolean("allowed", value) + } + + // Change the flag with the providers + analytics.setEnabled(value && !isInTestLab) // Never do analytics in the test lab + } + + /** Ask user to rate in play store */ + fun askToRate(activity: AppCompatActivity) { + if (!isGooglePlayAvailable()) return + + exceptionReporter { // we don't want to crash our app because of bugs in this optional feature + AppRating.Builder(activity) + .setMinimumLaunchTimes(10) // default is 5, 3 means app is launched 3 or more times + .setMinimumDays(10) // default is 5, 0 means install day, 10 means app is launched 10 or more days later than installation + .setMinimumLaunchTimesToShowAgain(5) // default is 5, 1 means app is launched 1 or more times after neutral button clicked + .setMinimumDaysToShowAgain(14) // default is 14, 1 means app is launched 1 or more days after neutral button clicked + .showIfMeetsConditions() + } + } + + override fun onCreate() { + super.onCreate() + + val firebaseAnalytics = com.geeksville.mesh.analytics.FirebaseAnalytics(this) + analytics = firebaseAnalytics + + // Set analytics per prefs + isAnalyticsAllowed = isAnalyticsAllowed + } +} + +fun Context.isGooglePlayAvailable(): Boolean { + return GoogleApiAvailabilityLight.getInstance() + .isGooglePlayServicesAvailable(this) + .let { + it != ConnectionResult.SERVICE_MISSING && + it != ConnectionResult.SERVICE_INVALID + } +} \ No newline at end of file diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt deleted file mode 100644 index 0583dd78e..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ /dev/null @@ -1,343 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.analytics - -import android.app.Application -import android.content.Context -import android.os.Bundle -import android.provider.Settings -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope -import co.touchlab.kermit.LogWriter -import co.touchlab.kermit.Severity -import com.datadog.android.Datadog -import com.datadog.android.DatadogSite -import com.datadog.android.core.configuration.Configuration -import com.datadog.android.log.Logger -import com.datadog.android.log.Logs -import com.datadog.android.log.LogsConfiguration -import com.datadog.android.privacy.TrackingConsent -import com.datadog.android.rum.GlobalRumMonitor -import com.datadog.android.rum.Rum -import com.datadog.android.rum.RumActionType -import com.datadog.android.rum.RumConfiguration -import com.datadog.android.sessionreplay.SessionReplay -import com.datadog.android.sessionreplay.SessionReplayConfiguration -import com.datadog.android.sessionreplay.TextAndInputPrivacy -import com.datadog.android.trace.Trace -import com.datadog.android.trace.TraceConfiguration -import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailabilityLight -import com.google.firebase.Firebase -import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus -import com.google.firebase.analytics.FirebaseAnalytics.ConsentType -import com.google.firebase.analytics.analytics -import com.google.firebase.crashlytics.crashlytics -import com.google.firebase.crashlytics.setCustomKeys -import com.google.firebase.initialize -import io.opentelemetry.api.GlobalOpenTelemetry -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Single -import org.meshtastic.app.BuildConfig -import org.meshtastic.core.repository.AnalyticsPrefs -import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.PlatformAnalytics -import co.touchlab.kermit.Logger as KermitLogger - -/** - * Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and - * Datadog services, and subscribes to analytics preference changes to update consent accordingly. - * - * This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and - * respect privacy-focused environments. - */ -@Single -class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) : - PlatformAnalytics { - - private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison - - private var datadogLogger: Logger? = null - private var isFirebaseInitialized = false - - private val isInTestLab: Boolean - get() { - val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab") - return "true" == testLabSetting - } - - companion object { - private const val TAG = "GooglePlatformAnalytics" - private const val SERVICE_NAME = "org.meshtastic" - - private const val KEY_PRIORITY = "priority" - private const val KEY_TAG = "tag" - private const val KEY_MESSAGE = "message" - } - - init { - // Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully. - val writers = buildList { - add(DatadogLogWriter()) - add(CrashlyticsLogWriter()) - if (BuildConfig.DEBUG) { - add(co.touchlab.kermit.LogcatWriter()) - } - } - KermitLogger.setLogWriters(writers) - KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info) - - // Initial consent state - updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value) - - // Subscribe to analytics preference changes - analyticsPrefs.analyticsAllowed - .onEach { allowed -> updateAnalyticsConsent(allowed) } - .launchIn(ProcessLifecycleOwner.get().lifecycleScope) - } - - /** - * Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted. - */ - private fun ensureInitialized() { - if (!analyticsPrefs.analyticsAllowed.value || isInTestLab) return - - if (!Datadog.isInitialized()) { - initDatadog(context as Application) - datadogLogger = - Logger.Builder() - .setService(SERVICE_NAME) - .setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID - .setRemoteSampleRate(sampleRate) - .setBundleWithTraceEnabled(true) - .setBundleWithRumEnabled(true) - .build() - } - - if (!isFirebaseInitialized) { - initCrashlytics(context as Application) - isFirebaseInitialized = true - } - } - - private fun initDatadog(application: Application) { - val configuration = - Configuration.Builder( - clientToken = BuildConfig.datadogClientToken, - env = if (BuildConfig.DEBUG) "Local" else "Production", - variant = BuildConfig.FLAVOR, - ) - .useSite(DatadogSite.US5) - .setCrashReportsEnabled(true) - .setUseDeveloperModeWhenDebuggable(true) - .build() - // Initialize with PENDING, consent will be updated via updateAnalyticsConsent - Datadog.initialize(application, configuration, TrackingConsent.PENDING) - Datadog.setVerbosity(if (BuildConfig.DEBUG) android.util.Log.DEBUG else android.util.Log.WARN) - - val rumConfiguration = - RumConfiguration.Builder(BuildConfig.datadogApplicationId) - .trackAnonymousUser(true) - .trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity - .trackFrustrations(false) // Disable click-tracking based frustration detection - .trackLongTasks() - .trackNonFatalAnrs(true) - .setSessionSampleRate(sampleRate) - .build() - Rum.enable(rumConfiguration) - - val logsConfig = LogsConfiguration.Builder().build() - Logs.enable(logsConfig) - - val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build() - Trace.enable(traceConfig) - - // Session Replay for debug builds only, matching Apple's TestFlight-only gating. - // Masks all text inputs to protect message content. - if (BuildConfig.DEBUG) { - val sessionReplayConfig = - SessionReplayConfiguration.Builder(sampleRate) - .setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL_INPUTS) - .build() - SessionReplay.enable(sessionReplayConfig) - } - - GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME)) - } - - private fun initCrashlytics(application: Application) { - Firebase.initialize(application) - - // Deny all ad-related consent types by default to minimize tracking noise - Firebase.analytics.setConsent( - mapOf( - ConsentType.AD_STORAGE to ConsentStatus.DENIED, - ConsentType.AD_USER_DATA to ConsentStatus.DENIED, - ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED, - ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED, - ), - ) - - // Explicitly disable analytics collection until we confirm user consent - Firebase.analytics.setAnalyticsCollectionEnabled(false) - } - - /** - * Updates the consent status for analytics, performance, and crash reporting services. - * - * @param allowed True if analytics are allowed, false otherwise. - */ - fun updateAnalyticsConsent(allowed: Boolean) { - if (isInTestLab) return - - if (allowed) { - ensureInitialized() - } - - KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" } - - if (Datadog.isInitialized()) { - Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED) - } - - if (isFirebaseInitialized) { - Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed - Firebase.analytics.setAnalyticsCollectionEnabled(allowed) - - if (allowed) { - Firebase.crashlytics.sendUnsentReports() - // Ensure ad-related PII collection remains disabled even if analytics is allowed - Firebase.analytics.setUserProperty("allow_personalized_ads", "false") - } - - // Manage Analytics Storage consent for Advanced Consent Mode - val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED - Firebase.analytics.setConsent( - mapOf( - ConsentType.ANALYTICS_STORAGE to consentStatus, - // Keep ad-related types explicitly denied - ConsentType.AD_STORAGE to ConsentStatus.DENIED, - ConsentType.AD_USER_DATA to ConsentStatus.DENIED, - ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED, - ), - ) - } - } - - override fun setDeviceAttributes(firmwareVersion: String, model: String) { - if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return - GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion()) - GlobalRumMonitor.get().addAttribute("device_hardware", model) - } - - override fun trackConnect( - firmwareVersion: String?, - transportType: String?, - hardwareModel: String?, - nodes: Int, - connectionRestored: Boolean, - ) { - if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return - val attributes = buildMap { - firmwareVersion?.let { put("firmwareVersion", it) } - transportType?.let { put("transportType", it) } - hardwareModel?.let { put("hardwareModel", it) } - put("nodes", nodes) - if (connectionRestored) put("connectionRestored", true) - } - GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes) - } - - private val isGooglePlayAvailable: Boolean - get() = - GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let { - it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID - } - - override val isPlatformServicesAvailable: Boolean - get() = isGooglePlayAvailable - - private inner class CrashlyticsLogWriter : LogWriter() { - override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - if (!isFirebaseInitialized) return - if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return - - // Add the log to the Crashlytics log buffer so it appears in reports - Firebase.crashlytics.log("$severity/$tag: $message") - - // Filter out normal coroutine cancellations - if (throwable is CancellationException) return - - // Only record non-fatal exceptions for actual Errors (Severity.Error or Severity.Assert) - if (severity >= Severity.Error) { - if (throwable != null) { - Firebase.crashlytics.recordException(throwable) - } else { - Firebase.crashlytics.setCustomKeys { - key(KEY_PRIORITY, severity.ordinal) - key(KEY_TAG, tag) - key(KEY_MESSAGE, message) - } - Firebase.crashlytics.recordException(Exception(message)) - } - } - } - } - - private inner class DatadogLogWriter : LogWriter() { - override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val logger = datadogLogger ?: return - val datadogPriority = - when (severity) { - Severity.Verbose -> android.util.Log.VERBOSE - Severity.Debug -> android.util.Log.DEBUG - Severity.Info -> android.util.Log.INFO - Severity.Warn -> android.util.Log.WARN - Severity.Error -> android.util.Log.ERROR - Severity.Assert -> android.util.Log.ASSERT - } - logger.log(datadogPriority, message, throwable, mapOf("tag" to tag)) - } - } - - private fun String.extractSemanticVersion(): String { - val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?$".toRegex() - val matchResult = regex.find(this) - return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this - } - - override fun track(event: String, vararg properties: DataPair) { - if (!isFirebaseInitialized) return - val bundle = Bundle() - properties.forEach { - val value = it.value - when (value) { - is Double -> bundle.putDouble(it.name, value) - is Int -> bundle.putLong(it.name, value.toLong()) // Firebase expects Long for integer values in bundles - is Long -> bundle.putLong(it.name, value) - is Float -> bundle.putDouble(it.name, value.toDouble()) - is String -> bundle.putString(it.name, value) // Explicitly handle String - else -> bundle.putString(it.name, value.toString()) // Fallback for other types - } - KermitLogger.withTag(TAG).d { "Analytics: track $event (${it.name} : $value)" } - } - Firebase.analytics.logEvent(event, bundle) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt deleted file mode 100644 index 802f3b150..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import org.koin.core.annotation.Module -import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule - -@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class]) -class FlavorModule diff --git a/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt deleted file mode 100644 index eede9d6e3..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single -import org.meshtastic.core.network.service.ApiService -import org.meshtastic.core.network.service.ApiServiceImpl - -@Module -class GoogleNetworkModule { - - @Single fun bindApiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl -} diff --git a/app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt b/app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt deleted file mode 100644 index fdad2c363..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/intro/AnalyticsIntro.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.intro - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.analytics_notice -import org.meshtastic.core.resources.analytics_platforms -import org.meshtastic.core.resources.datadog_link -import org.meshtastic.core.resources.firebase_link -import org.meshtastic.core.resources.for_more_information_see_our_privacy_policy -import org.meshtastic.core.resources.privacy_url -import org.meshtastic.core.ui.component.AutoLinkText - -@Composable -fun AnalyticsIntro(modifier: Modifier = Modifier) { - Column( - modifier = modifier.fillMaxWidth().padding(top = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(Res.string.analytics_notice), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - textAlign = TextAlign.Center, - text = stringResource(Res.string.analytics_platforms), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - AutoLinkText( - text = stringResource(Res.string.firebase_link), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - AutoLinkText( - text = stringResource(Res.string.datadog_link), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Text( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - textAlign = TextAlign.Center, - text = stringResource(Res.string.for_more_information_see_our_privacy_policy), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AutoLinkText( - text = stringResource(Res.string.privacy_url), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun AnalyticsIntroPreview() { - AnalyticsIntro() -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 8a441fa70..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider() diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt deleted file mode 100644 index 940c4ab5a..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.annotation.Single -import org.meshtastic.core.ui.util.MapViewProvider - -/** Google Maps implementation of [MapViewProvider]. */ -@Single -class GoogleMapViewProvider : MapViewProvider { - @Composable - override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { - val mapViewModel: MapViewModel = koinViewModel() - LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - org.meshtastic.app.map.MapView( - modifier = modifier, - mapViewModel = mapViewModel, - navigateToNodeDetails = navigateToNodeDetails, - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt b/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt deleted file mode 100644 index 1aa4a7bab..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.Manifest -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat -import co.touchlab.kermit.Logger -import com.google.android.gms.common.api.ResolvableApiException -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.LocationSettingsRequest -import com.google.android.gms.location.Priority - -private const val INTERVAL_MILLIS = 10000L - -@Suppress("LongMethod") -@Composable -fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) { - val context = LocalContext.current - var localHasPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED, - ) - } - - val requestLocationPermissionLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted -> - localHasPermission = isGranted - // Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via - // onPermissionResult - // if permission is granted. If not granted, immediately report false. - if (!isGranted) { - onPermissionResult(false) - } - } - - val locationSettingsLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - Logger.d { "Location settings changed by user." } - // User has enabled location services or improved accuracy. - onPermissionResult(true) // Settings are now adequate, and permission was already granted. - } else { - Logger.d { "Location settings change cancelled by user." } - // User chose not to change settings. The permission itself is still granted, - // but the experience might be degraded. For the purpose of enabling map features, - // we consider this as success if the core permission is there. - // If stricter handling is needed (e.g., block feature if settings not optimal), - // this logic might change. - onPermissionResult(localHasPermission) - } - } - - LaunchedEffect(Unit) { - // Initial permission check - when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) { - PackageManager.PERMISSION_GRANTED -> { - if (!localHasPermission) { - localHasPermission = true - } - // If permission is already granted, proceed to check location settings. - // The LaunchedEffect(localHasPermission) will handle this. - // No need to call onPermissionResult(true) here yet, let settings check complete. - } - - else -> { - // Request permission if not granted. The launcher's callback will update localHasPermission. - requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - } - - LaunchedEffect(localHasPermission) { - // Handles logic after permission status is known/updated - if (localHasPermission) { - // Permission is granted, now check location settings - val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build() - - val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) - - val client = LocationServices.getSettingsClient(context) - val task = client.checkLocationSettings(builder.build()) - - task.addOnSuccessListener { - Logger.d { "Location settings are satisfied." } - onPermissionResult(true) // Permission granted and settings are good - } - - task.addOnFailureListener { exception -> - if (exception is ResolvableApiException) { - try { - val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build() - locationSettingsLauncher.launch(intentSenderRequest) - // Result of this launch will be handled by locationSettingsLauncher's callback - } catch (sendEx: ActivityNotFoundException) { - Logger.d { "Error launching location settings resolution ${sendEx.message}." } - onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed. - } - } else { - Logger.d { "Location settings are not satisfiable.${exception.message}" } - onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed. - } - } - } else { - // If permission is not granted, report false. - // This case is primarily handled by the requestLocationPermissionLauncher's callback - // if the initial state was denied, or if user denies it. - onPermissionResult(false) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt deleted file mode 100644 index 6ac756f6b..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.database.sqlite.SQLiteDatabase -import com.google.android.gms.maps.model.Tile -import com.google.android.gms.maps.model.TileProvider -import java.io.File - -class MBTilesProvider(private val file: File) : - TileProvider, - AutoCloseable { - private var database: SQLiteDatabase? = null - - init { - openDatabase() - } - - private fun openDatabase() { - if (database == null && file.exists()) { - database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) - } - } - - override fun getTile(x: Int, y: Int, zoom: Int): Tile? { - val db = database ?: return null - - var tile: Tile? = null - // Convert Google Maps y coordinate to standard TMS y coordinate - val tmsY = (1 shl zoom) - 1 - y - - val cursor = - db.rawQuery( - "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", - arrayOf(zoom.toString(), x.toString(), tmsY.toString()), - ) - - if (cursor.moveToFirst()) { - val tileData = cursor.getBlob(0) - tile = Tile(256, 256, tileData) - } - cursor.close() - - return tile ?: TileProvider.NO_TILE - } - - override fun close() { - database?.close() - database = null - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index c8f2f3fee..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,1125 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.view.WindowManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority -import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.JointType -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.LatLngBounds -import com.google.maps.android.SphericalUtil -import com.google.maps.android.compose.CameraPositionState -import com.google.maps.android.compose.ComposeMapColorScheme -import com.google.maps.android.compose.GoogleMap -import com.google.maps.android.compose.MapEffect -import com.google.maps.android.compose.MapProperties -import com.google.maps.android.compose.MapType -import com.google.maps.android.compose.MapUiSettings -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.MarkerComposable -import com.google.maps.android.compose.MarkerInfoWindowComposable -import com.google.maps.android.compose.Polyline -import com.google.maps.android.compose.TileOverlay -import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberUpdatedMarkerState -import com.google.maps.android.compose.widgets.ScaleBar -import com.google.maps.android.data.Layer -import com.google.maps.android.data.geojson.GeoJsonLayer -import com.google.maps.android.data.kml.KmlLayer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.json.JSONObject -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.component.ClusterItemsListDialog -import org.meshtastic.app.map.component.CustomMapLayersSheet -import org.meshtastic.app.map.component.CustomTileProviderManagerSheet -import org.meshtastic.app.map.component.EditWaypointDialog -import org.meshtastic.app.map.component.MapFilterDropdown -import org.meshtastic.app.map.component.MapTypeDropdown -import org.meshtastic.app.map.component.NodeClusterMarkers -import org.meshtastic.app.map.component.NodeMapFilterDropdown -import org.meshtastic.app.map.component.WaypointMarkers -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG -import org.meshtastic.core.model.util.metersIn -import org.meshtastic.core.model.util.mpsToKmph -import org.meshtastic.core.model.util.mpsToMph -import org.meshtastic.core.model.util.toString -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.alt -import org.meshtastic.core.resources.heading -import org.meshtastic.core.resources.latitude -import org.meshtastic.core.resources.longitude -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_tile_source -import org.meshtastic.core.resources.position -import org.meshtastic.core.resources.sats -import org.meshtastic.core.resources.speed -import org.meshtastic.core.resources.timestamp -import org.meshtastic.core.resources.track_point -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.Map -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.TripOrigin -import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.formatAgo -import org.meshtastic.core.ui.util.formatPositionTime -import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState -import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Position -import org.meshtastic.proto.Waypoint -import kotlin.math.abs -import kotlin.math.max - -// region --- Map Mode --- - -/** - * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed - * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers, - * controls overlay) is available in every mode. - */ -sealed interface GoogleMapMode { - /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */ - data object Main : GoogleMapMode - - /** Focused node position track: polyline + gradient markers for historical positions. */ - data class NodeTrack( - val focusedNode: Node?, - val positions: List, - val selectedPositionTime: Int? = null, - val onPositionSelected: ((Int) -> Unit)? = null, - ) : GoogleMapMode - - /** Traceroute visualization: offset forward/return polylines + hop markers. */ - data class Traceroute( - val overlay: TracerouteOverlay?, - val nodePositions: Map, - val onMappableCountChanged: (shown: Int, total: Int) -> Unit, - ) : GoogleMapMode -} - -// endregion - -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 - -@Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit = {}, - mode: GoogleMapMode = GoogleMapMode.Main, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - - // --- Location permissions --- - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - // --- Location tracking --- - var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var followPhoneBearing by remember { mutableStateOf(false) } - - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - isLocationTrackingEnabled = true - triggerLocationToggleAfterPermission = false - } - } - - // --- File picker for map layers (Main mode) --- - val filePickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - mapViewModel.addMapLayer(uri, fileName) - } - } - } - - // --- UI state --- - var mapFilterMenuExpanded by remember { mutableStateOf(false) } - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() - var editingWaypoint by remember { mutableStateOf(null) } - - val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle() - val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle() - - var mapTypeMenuExpanded by remember { mutableStateOf(false) } - var showCustomTileManagerSheet by remember { mutableStateOf(false) } - - // --- Camera --- - // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering. - val cameraPositionState = - if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState() - - if (mode is GoogleMapMode.Main) { - LaunchedEffect(cameraPositionState.isMoving) { - if (!cameraPositionState.isMoving) { - mapViewModel.saveCameraPosition(cameraPositionState.position) - } - } - } - - // --- FusedLocation --- - val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } - val locationCallback = remember { - object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - if (isLocationTrackingEnabled) { - locationResult.lastLocation?.let { location -> - val latLng = LatLng(location.latitude, location.longitude) - val cameraUpdate = - if (followPhoneBearing) { - val bearing = - if (location.hasBearing()) { - location.bearing - } else { - cameraPositionState.position.bearing - } - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(latLng) - .zoom(cameraPositionState.position.zoom) - .bearing(bearing) - .build(), - ) - } else { - CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom) - } - coroutineScope.launch { - try { - cameraPositionState.animate(cameraUpdate) - } catch (e: IllegalStateException) { - Logger.d { "Error animating camera to location: ${e.message}" } - } - } - } - } - } - } - } - - LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { - if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { - val locationRequest = - LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) - .setMinUpdateIntervalMillis(2000L) - .build() - try { - @Suppress("MissingPermission") - fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null) - Logger.d { "Started location tracking" } - } catch (e: SecurityException) { - Logger.d { "Location permission not available: ${e.message}" } - isLocationTrackingEnabled = false - } - } else { - fusedLocationClient.removeLocationUpdates(locationCallback) - Logger.d { "Stopped location tracking" } - } - } - - DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } } - - // --- Node & waypoint data --- - val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } - val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - - val filteredNodes = - allNodes - .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } - .filter { node -> - mapFilterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || - node.num == ourNodeInfo?.num - } - - val myNodeNum = mapViewModel.myNodeNum - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - val theme by mapViewModel.theme.collectAsStateWithLifecycle() - val dark = - when (theme) { - AppCompatDelegate.MODE_NIGHT_YES -> true - AppCompatDelegate.MODE_NIGHT_NO -> false - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() - else -> isSystemInDarkTheme() - } - val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT - - // --- Mode-specific data --- - // Node track: apply time filter - val sortedTrackPositions = - if (mode is GoogleMapMode.NodeTrack) { - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - remember(mode.positions, lastHeardTrackFilter) { - mode.positions - .filter { - lastHeardTrackFilter == LastHeardFilter.Any || - it.time > nowSeconds - lastHeardTrackFilter.seconds - } - .sortedBy { it.time } - } - } else { - emptyList() - } - - // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules - // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all - // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops - // whose positions come from snapshots. - val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) - val tracerouteSelection = - if (mode is GoogleMapMode.Traceroute) { - remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = mode.overlay, - tracerouteNodePositions = mode.nodePositions, - nodes = allNodesForTraceroute, - ) - } - } else { - null - } - val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList() - - if (mode is GoogleMapMode.Traceroute) { - LaunchedEffect(mode.overlay, tracerouteDisplayNodes) { - if (mode.overlay != null) { - mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size) - } - } - } - - val tracerouteForwardPoints: List = - if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { - val nodeLookup = tracerouteSelection.nodeLookup - remember(mode.overlay, nodeLookup) { - mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() - } - } else { - emptyList() - } - val tracerouteReturnPoints: List = - if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { - val nodeLookup = tracerouteSelection.nodeLookup - remember(mode.overlay, nodeLookup) { - mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() - } - } else { - emptyList() - } - val tracerouteHeadingReferencePoints = - remember(tracerouteForwardPoints, tracerouteReturnPoints) { - when { - tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints - tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints - else -> emptyList() - } - } - val tracerouteForwardOffsetPoints = - remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0) - } - val tracerouteReturnOffsetPoints = - remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0) - } - - // Auto-centering for NodeTrack / Traceroute modes - var hasCentered by remember(mode) { mutableStateOf(false) } - - if (mode is GoogleMapMode.NodeTrack) { - LaunchedEffect(sortedTrackPositions, hasCentered) { - if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect - val points = sortedTrackPositions.map { it.toLatLng() } - val cameraUpdate = - if (points.size == 1) { - CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f)) - } else { - val bounds = LatLngBounds.builder() - points.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), 80) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCentered = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering track map: ${e.message}" } - } - } - - // Animate to selected position marker when card is tapped in the list - LaunchedEffect(mode.selectedPositionTime) { - val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect - val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect - try { - cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng())) - } catch (e: IllegalStateException) { - Logger.d { "Error animating to selected position: ${e.message}" } - } - } - } - - if (mode is GoogleMapMode.Traceroute) { - LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (mode.overlay == null || hasCentered) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - val cameraUpdate = - if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom( - allPoints.first(), - max(cameraPositionState.position.zoom, 12f), - ) - } else { - val bounds = LatLngBounds.builder() - allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCentered = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering traceroute overlay: ${e.message}" } - } - } - } - } - - // --- Tile & layers state --- - var showLayersBottomSheet by remember { mutableStateOf(false) } - - val onAddLayerClicked = { - val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - val mimeTypes = - arrayOf( - "application/vnd.google-earth.kml+xml", - "application/vnd.google-earth.kmz", - "application/vnd.geo+json", - "application/geo+json", - "application/json", - ) - putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) - } - filePickerLauncher.launch(intent) - } - val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) } - val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) } - - val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType - - var showClusterItemsDialog by remember { mutableStateOf?>(null) } - - // --- Keep screen on while location tracking --- - LaunchedEffect(isLocationTrackingEnabled) { - val activity = context as? Activity ?: return@LaunchedEffect - val window = activity.window - if (isLocationTrackingEnabled) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - // --- Main UI --- - val isMainMode = mode is GoogleMapMode.Main - - Box(modifier = modifier) { - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = isMainMode, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = true, - scrollGesturesEnabled = true, - tiltGesturesEnabled = isMainMode, - zoomGesturesEnabled = true, - ), - properties = - MapProperties( - mapType = effectiveGoogleMapType, - isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, - ), - onMapLongClick = { latLng -> - if (isMainMode && isConnected) { - editingWaypoint = - Waypoint( - latitude_i = (latLng.latitude / DEG_D).toInt(), - longitude_i = (latLng.longitude / DEG_D).toInt(), - ) - } - }, - ) { - // Custom tile overlay (all modes) - key(currentCustomTileProviderUrl) { - currentCustomTileProviderUrl?.let { url -> - val config = - mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { - it.urlTemplate == url || it.localUri == url - } - mapViewModel.getTileProvider(config)?.let { tileProvider -> - TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) - } - } - } - - when (mode) { - is GoogleMapMode.Main -> - MainMapContent( - nodeClusterItems = - filteredNodes.map { node -> - val latLng = - LatLng( - (node.position.latitude_i ?: 0) * DEG_D, - (node.position.longitude_i ?: 0) * DEG_D, - ) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.long_name}", - myNodeNum = myNodeNum, - ) - }, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - displayableWaypoints = displayableWaypoints, - myNodeNum = myNodeNum, - isConnected = isConnected, - onEditWaypointRequest = { editingWaypoint = it }, - selectedWaypointId = selectedWaypointId, - mapLayers = mapLayers, - mapViewModel = mapViewModel, - cameraPositionState = cameraPositionState, - coroutineScope = coroutineScope, - onShowClusterItemsDialog = { showClusterItemsDialog = it }, - ) - - is GoogleMapMode.NodeTrack -> { - val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() - if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) { - NodeTrackOverlay( - focusedNode = mode.focusedNode, - sortedPositions = sortedTrackPositions, - displayUnits = displayUnits, - myNodeNum = myNodeNum, - selectedPositionTime = mode.selectedPositionTime, - onPositionSelected = mode.onPositionSelected, - ) - } - } - - is GoogleMapMode.Traceroute -> - TracerouteMapContent( - forwardOffsetPoints = tracerouteForwardOffsetPoints, - returnOffsetPoints = tracerouteReturnOffsetPoints, - forwardPointCount = tracerouteForwardPoints.size, - returnPointCount = tracerouteReturnPoints.size, - displayNodes = tracerouteDisplayNodes, - ) - } - } - - // Scale bar - ScaleBar( - cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp), - ) - - // Waypoint edit dialog (Main mode only) - if (isMainMode) { - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) - } - if ((updatedWp.icon ?: 0) == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { - mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1)) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) - } - } - - // Controls overlay - val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } - val showRefresh = visibleNetworkLayers.isNotEmpty() - val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } - - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterMenuExpanded = true }, - filterDropdownContent = { - if (mode is GoogleMapMode.NodeTrack) { - NodeMapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = { mapFilterMenuExpanded = false }, - mapViewModel = mapViewModel, - ) - } else { - MapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = { mapFilterMenuExpanded = false }, - mapViewModel = mapViewModel, - ) - } - }, - mapTypeContent = { - Box { - MapButton( - icon = MeshtasticIcons.Map, - contentDescription = stringResource(Res.string.map_tile_source), - onClick = { mapTypeMenuExpanded = true }, - ) - MapTypeDropdown( - expanded = mapTypeMenuExpanded, - onDismissRequest = { mapTypeMenuExpanded = false }, - mapViewModel = mapViewModel, - onManageCustomTileProvidersClicked = { - mapTypeMenuExpanded = false - showCustomTileManagerSheet = true - }, - ) - } - }, - layersContent = { - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.manage_map_layers), - onClick = { showLayersBottomSheet = true }, - ) - }, - isLocationTrackingEnabled = isLocationTrackingEnabled, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followPhoneBearing = false - } - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - bearing = cameraPositionState.position.bearing, - onCompassClick = { - if (isLocationTrackingEnabled) { - followPhoneBearing = !followPhoneBearing - } else { - coroutineScope.launch { - try { - val currentPosition = cameraPositionState.position - val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build() - cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition)) - Logger.d { "Oriented map to north" } - } catch (e: IllegalStateException) { - Logger.d { "Error orienting map to north: ${e.message}" } - } - } - } - }, - followPhoneBearing = followPhoneBearing, - showRefresh = showRefresh, - isRefreshing = isRefreshingLayers, - onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, - ) - } - - // --- Bottom sheets & dialogs --- - if (showLayersBottomSheet) { - ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { - CustomMapLayersSheet( - mapLayers = mapLayers, - onToggleVisibility = onToggleVisibility, - onRemoveLayer = onRemoveLayer, - onAddLayerClicked = onAddLayerClicked, - onRefreshLayer = { mapViewModel.refreshMapLayer(it) }, - onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) }, - ) - } - } - showClusterItemsDialog?.let { - ClusterItemsListDialog( - items = it, - onDismiss = { showClusterItemsDialog = null }, - onItemClick = { item -> - navigateToNodeDetails(item.node.num) - showClusterItemsDialog = null - }, - ) - } - if (showCustomTileManagerSheet) { - ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) { - CustomTileProviderManagerSheet(mapViewModel = mapViewModel) - } - } -} - -// region --- Main Map Content --- - -@Suppress("LongParameterList") -@OptIn(MapsComposeExperimentalApi::class) -@Composable -private fun MainMapContent( - nodeClusterItems: List, - mapFilterState: MapFilterState, - navigateToNodeDetails: (Int) -> Unit, - displayableWaypoints: List, - myNodeNum: Int?, - isConnected: Boolean, - onEditWaypointRequest: (Waypoint) -> Unit, - selectedWaypointId: Int?, - mapLayers: List, - mapViewModel: MapViewModel, - cameraPositionState: CameraPositionState, - coroutineScope: CoroutineScope, - onShowClusterItemsDialog: (List?) -> Unit, -) { - NodeClusterMarkers( - nodeClusterItems = nodeClusterItems, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - onClusterClick = { cluster -> - val items = cluster.items.toList() - val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } - if (allSameLocation) { - onShowClusterItemsDialog(items) - } else { - val bounds = LatLngBounds.builder() - cluster.items.forEach { bounds.include(it.position) } - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, - mapFilterState = mapFilterState, - myNodeNum = myNodeNum ?: 0, - isConnected = isConnected, - onEditWaypointRequest = onEditWaypointRequest, - selectedWaypointId = selectedWaypointId, - ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } -} - -// endregion - -// region --- Node Track Overlay --- - -/** - * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from - * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a - * [TripOrigin] dot with an info-window on tap. - * - * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and - * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization. - */ -@OptIn(MapsComposeExperimentalApi::class) -@Composable -@Suppress("LongMethod") -private fun NodeTrackOverlay( - focusedNode: Node, - sortedPositions: List, - displayUnits: DisplayUnits, - myNodeNum: Int?, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - val selectedColor = MaterialTheme.colorScheme.primary - - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = - if (sortedPositions.size > 1) { - index.toFloat() / (sortedPositions.size.toFloat() - 1) - } else { - 1f - } - val isSelected = position.time == selectedPositionTime - val color = - if (isSelected) { - selectedColor - } else { - Color(focusedNode.colors.second).copy(alpha = alpha) - } - - if (index == sortedPositions.lastIndex) { - MarkerComposable( - state = markerState, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - ) { - NodeChip(node = focusedNode) - } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, - ) { - Icon( - imageVector = MeshtasticIcons.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - modifier = if (isSelected) Modifier.size(32.dp) else Modifier, - ) - } - } - } - } - - // Gradient polyline segments - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = index.toFloat() / (segments.size.toFloat() - 1) - Polyline( - points = segmentPoints.map { it.toLatLng() }, - jointType = JointType.ROUND, - color = Color(focusedNode.colors.second).copy(alpha = alpha), - width = 8f, - zIndex = 0.6f, - ) - } - } -} - -@Composable -@Suppress("LongMethod") -private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) { - @Composable - fun PositionRow(label: String, value: String) { - Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.labelMedium) - Spacer(modifier = Modifier.width(16.dp)) - Text(value, style = MaterialTheme.typography.labelMedium) - } - } - - Card { - Column(modifier = Modifier.padding(8.dp)) { - PositionRow( - label = stringResource(Res.string.latitude), - value = "%.5f".format((position.latitude_i ?: 0) * DEG_D), - ) - PositionRow( - label = stringResource(Res.string.longitude), - value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), - ) - PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString()) - PositionRow( - label = stringResource(Res.string.alt), - value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), - ) - PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) - PositionRow( - label = stringResource(Res.string.heading), - value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), - ) - PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) - } - } -} - -@Composable -private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { - val speedInMps = position.ground_speed ?: 0 - val mpsText = "%d m/s".format(speedInMps) - return if (speedInMps > 10) { - when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) - else -> mpsText - } - } else { - mpsText - } -} - -// endregion - -// region --- Traceroute Map Content --- - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -private fun TracerouteMapContent( - forwardOffsetPoints: List, - returnOffsetPoints: List, - forwardPointCount: Int, - returnPointCount: Int, - displayNodes: List, -) { - if (forwardPointCount >= 2) { - Polyline( - points = forwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 3.0f, - ) - } - if (returnPointCount >= 2) { - Polyline( - points = returnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 2.5f, - ) - } - displayNodes.forEach { node -> - val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng()) - MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) } - } -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - SphericalUtil.computeHeading( - headingPoints[headingPoints.lastIndex - 1], - headingPoints[headingPoints.lastIndex], - ) - - else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (90.0 * sideMultiplier) - SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading) - } -} - -// endregion - -// region --- Map Layers --- - -@Composable -private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { - val context = LocalContext.current - var currentLayer by remember { mutableStateOf(null) } - - MapEffect(layerItem.id, layerItem.isRefreshing) { map -> - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect - val layer = - try { - when (layerItem.layerType) { - LayerType.KML -> KmlLayer(map, inputStream, context) - LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) - } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } - null - } - layer?.let { - if (layerItem.isVisible) it.safeAddLayerToMap() - currentLayer = it - } - } - - DisposableEffect(layerItem.id) { - onDispose { - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - } - } - - LaunchedEffect(layerItem.isVisible) { - val layer = currentLayer ?: return@LaunchedEffect - if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap() - } -} - -private fun Layer.safeRemoveLayerFromMap() { - try { - removeLayerFromMap() - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error removing map layer" } - } -} - -private fun Layer.safeAddLayerToMap() { - try { - if (!isLayerOnMap) addLayerToMap() - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error adding map layer" } - } -} - -// endregion - -// region --- Utilities --- - -internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { - String(Character.toChars(unicodeCodePoint)) -} catch (e: IllegalArgumentException) { - Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } - "\uD83D\uDCCD" -} - -@Suppress("NestedBlockDepth") -fun Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "layer_$nowMillis" - if (this.scheme == "content") { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1) { - name = cursor.getString(displayNameIndex) - } - } - } - } - return name -} - -/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */ -internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) - -// endregion diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index e4eabbb76..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,688 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.app.Application -import android.net.Uri -import androidx.core.net.toFile -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.TileProvider -import com.google.android.gms.maps.model.UrlTileProvider -import com.google.maps.android.compose.CameraPositionState -import com.google.maps.android.compose.MapType -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.isSuccess -import io.ktor.utils.io.jvm.javaio.toInputStream -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.app.map.repository.CustomTileProviderRepository -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.Config -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream -import java.net.MalformedURLException -import java.net.URL -import kotlin.uuid.Uuid - -private const val TILE_SIZE = 256 - -@Serializable -data class MapCameraPosition( - val targetLat: Double, - val targetLng: Double, - val zoom: Float, - val tilt: Float, - val bearing: Float, -) - -@Suppress("TooManyFunctions", "LongParameterList") -@KoinViewModel -class MapViewModel( - private val application: Application, - private val dispatchers: CoroutineDispatchers, - private val httpClient: HttpClient, - mapPrefs: MapPrefs, - private val googleMapsPrefs: GoogleMapsPrefs, - nodeRepository: NodeRepository, - packetRepository: PacketRepository, - radioConfigRepository: RadioConfigRepository, - radioController: RadioController, - private val customTileProviderRepository: CustomTileProviderRepository, - uiPrefs: UiPrefs, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - if (id != null) { - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(id) } - wpMap[id]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - } - - private val targetLatLng = - googleMapsPrefs.cameraTargetLat.value - .takeIf { it != 0.0 } - ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } } - ?: ourNodeInfo.value?.position?.toLatLng() - ?: LatLng(0.0, 0.0) - - val cameraPositionState = - CameraPositionState( - position = - CameraPosition( - targetLatLng, - googleMapsPrefs.cameraZoom.value, - googleMapsPrefs.cameraTilt.value, - googleMapsPrefs.cameraBearing.value, - ), - ) - - val theme: StateFlow = uiPrefs.theme - - private val _errorFlow = MutableSharedFlow() - val errorFlow: SharedFlow = _errorFlow.asSharedFlow() - - val customTileProviderConfigs: StateFlow> = - customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList()) - - private val _selectedCustomTileProviderUrl = MutableStateFlow(null) - val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow() - - private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL) - val selectedGoogleMapType: StateFlow = _selectedGoogleMapType.asStateFlow() - - val displayUnits = - radioConfigRepository.deviceProfileFlow - .mapNotNull { it.config?.display?.units } - .stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC) - - fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) { - viewModelScope.launch { - if ( - name.isBlank() || - (urlTemplate.isBlank() && localUri == null) || - (localUri == null && !isValidTileUrlTemplate(urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.") - return@launch - } - if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) { - _errorFlow.emit("Custom tile provider with name '$name' already exists.") - return@launch - } - - var finalLocalUri = localUri - if (localUri != null) { - try { - val uri = Uri.parse(localUri) - val extension = "mbtiles" - val finalFileName = "mbtiles_${Uuid.random()}.$extension" - val copiedUri = copyFileToInternalStorage(uri, finalFileName) - if (copiedUri != null) { - finalLocalUri = copiedUri.toString() - } else { - _errorFlow.emit("Failed to copy MBTiles file to internal storage.") - return@launch - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error processing local URI" } - _errorFlow.emit("Error processing local URI for MBTiles.") - return@launch - } - } - - val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri) - customTileProviderRepository.addCustomTileProvider(newConfig) - } - } - - fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) { - viewModelScope.launch { - if ( - configToUpdate.name.isBlank() || - (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) || - (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.") - return@launch - } - val existingConfigs = customTileProviderConfigs.value - if ( - existingConfigs.any { - it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true) - } - ) { - _errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.") - return@launch - } - - customTileProviderRepository.updateCustomTileProvider(configToUpdate) - - val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id) - if ( - _selectedCustomTileProviderUrl.value != null && - originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value - ) { - // No change needed if URL didn't change, or handle if it did - } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) { - val currentlySelectedConfig = - customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value } - if (currentlySelectedConfig?.id == configToUpdate.id) { - _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate - } - } - } - } - - fun removeCustomTileProvider(configId: String) { - viewModelScope.launch { - val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId) - customTileProviderRepository.deleteCustomTileProvider(configId) - - if (configToRemove != null) { - if ( - _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate || - _selectedCustomTileProviderUrl.value == configToRemove.localUri - ) { - _selectedCustomTileProviderUrl.value = null - // Also clear from prefs - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - if (configToRemove.localUri != null) { - val uri = Uri.parse(configToRemove.localUri) - deleteFileToInternalStorage(uri) - } - } - } - } - - fun selectCustomTileProvider(config: CustomTileProviderConfig?) { - if (config != null) { - if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) { - Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}") - _selectedCustomTileProviderUrl.value = null - googleMapsPrefs.setSelectedCustomTileUrl(null) - return - } - // Use localUri if present, otherwise urlTemplate - val selectedUrl = config.localUri ?: config.urlTemplate - _selectedCustomTileProviderUrl.value = selectedUrl - _selectedGoogleMapType.value = MapType.NONE - googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl) - googleMapsPrefs.setSelectedGoogleMapType(null) - } else { - _selectedCustomTileProviderUrl.value = null - _selectedGoogleMapType.value = MapType.NORMAL - googleMapsPrefs.setSelectedCustomTileUrl(null) - googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name) - } - } - - fun setSelectedGoogleMapType(mapType: MapType) { - _selectedGoogleMapType.value = mapType - _selectedCustomTileProviderUrl.value = null // Clear custom selection - googleMapsPrefs.setSelectedGoogleMapType(mapType.name) - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - private var currentTileProvider: TileProvider? = null - - fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? { - if (config == null) { - (currentTileProvider as? MBTilesProvider)?.close() - currentTileProvider = null - return null - } - - val selectedUrl = config.localUri ?: config.urlTemplate - if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) { - return currentTileProvider - } - - // Close previous if it was a local provider - (currentTileProvider as? MBTilesProvider)?.close() - - val newProvider = - if (config.isLocal) { - val uri = Uri.parse(config.localUri) - val file = - try { - uri.toFile() - } catch (e: Exception) { - File(uri.path ?: "") - } - if (file.exists()) { - MBTilesProvider(file) - } else { - Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}") - null - } - } else { - val urlString = config.urlTemplate - if (!isValidTileUrlTemplate(urlString)) { - Logger.withTag("MapViewModel") - .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString") - null - } else { - object : UrlTileProvider(TILE_SIZE, TILE_SIZE) { - override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? { - val subdomains = listOf("a", "b", "c") - val subdomain = subdomains[(x + y) % subdomains.size] - val formattedUrl = - urlString - .replace("{s}", subdomain, ignoreCase = true) - .replace("{z}", zoom.toString(), ignoreCase = true) - .replace("{x}", x.toString(), ignoreCase = true) - .replace("{y}", y.toString(), ignoreCase = true) - return try { - URL(formattedUrl) - } catch (e: MalformedURLException) { - Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" } - null - } - } - } - } - } - - currentTileProvider = newProvider - return newProvider - } - - private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) && - urlTemplate.contains("{x}", ignoreCase = true) && - urlTemplate.contains("{y}", ignoreCase = true) - - private val _mapLayers = MutableStateFlow>(emptyList()) - val mapLayers: StateFlow> = _mapLayers.asStateFlow() - - init { - viewModelScope.launch { - customTileProviderRepository.getCustomTileProviders().first() - loadPersistedMapType() - } - loadPersistedLayers() - - selectedWaypointId.value?.let { wpId -> - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(wpId) } - wpMap[wpId]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - - fun saveCameraPosition(cameraPosition: CameraPosition) { - viewModelScope.launch { - googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude) - googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude) - googleMapsPrefs.setCameraZoom(cameraPosition.zoom) - googleMapsPrefs.setCameraTilt(cameraPosition.tilt) - googleMapsPrefs.setCameraBearing(cameraPosition.bearing) - } - } - - private fun loadPersistedMapType() { - val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value - if (savedCustomUrl != null) { - // Check if this custom provider still exists - if ( - customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } && - isValidTileUrlTemplate(savedCustomUrl) - ) { - _selectedCustomTileProviderUrl.value = savedCustomUrl - _selectedGoogleMapType.value = - MapType.NONE // MapType.NONE to hide google basemap when using custom provider - } else { - // The saved custom URL is no longer valid or doesn't exist, remove preference - googleMapsPrefs.setSelectedCustomTileUrl(null) - // Fallback to default Google Map type - _selectedGoogleMapType.value = MapType.NORMAL - } - } else { - val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value - try { - _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name) - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" } - _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name - googleMapsPrefs.setSelectedGoogleMapType(null) - } - } - } - - private fun loadPersistedLayers() { - viewModelScope.launch(dispatchers.io) { - try { - val layersDir = File(application.filesDir, "map_layers") - if (layersDir.exists() && layersDir.isDirectory) { - val persistedLayerFiles = layersDir.listFiles() - - if (persistedLayerFiles != null) { - val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = - persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - "geojson", - "json", - -> LayerType.GEOJSON - else -> null - } - - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) - } - } else { - null - } - } - - val networkItems = - googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> - try { - val parts = networkString.split("|:|") - if (parts.size == 3) { - val id = parts[0] - val name = parts[1] - val uri = Uri.parse(parts[2]) - MapLayerItem( - id = id, - name = name, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = LayerType.KML, - isNetwork = true, - ) - } else { - null - } - } catch (e: Exception) { - null - } - } - - _mapLayers.value = loadedItems + networkItems - if (_mapLayers.value.isNotEmpty()) { - Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.") - } - } - } else { - Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.") - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" } - _mapLayers.value = emptyList() - } - } - } - - fun addMapLayer(uri: Uri, fileName: String?) { - viewModelScope.launch { - val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}" - - val extension = - fileName?.substringAfterLast('.', "")?.lowercase() - ?: application.contentResolver.getType(uri)?.split('/')?.last() - - val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz") - val geoJsonExtensions = listOf("geojson", "json") - - val layerType = - when (extension) { - in kmlExtensions -> LayerType.KML - in geoJsonExtensions -> LayerType.GEOJSON - else -> null - } - - if (layerType == null) { - Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension") - return@launch - } - - val finalFileName = - if (fileName != null) { - "$layerName.$extension" - } else { - "layer_${Uuid.random()}.$extension" - } - - val localFileUri = copyFileToInternalStorage(uri, finalFileName) - - if (localFileUri != null) { - val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) - _mapLayers.value = _mapLayers.value + newItem - } else { - Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.") - } - } - } - - fun addNetworkMapLayer(name: String, url: String) { - viewModelScope.launch { - if (name.isBlank() || url.isBlank()) { - _errorFlow.emit("Invalid name or URL for network layer.") - return@launch - } - try { - val uri = Uri.parse(url) - if (uri.scheme != "http" && uri.scheme != "https") { - _errorFlow.emit("URL must be http or https.") - return@launch - } - - val path = uri.path?.lowercase() ?: "" - val layerType = - when { - path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON - else -> LayerType.KML // Default to KML - } - - val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true) - _mapLayers.value = _mapLayers.value + newItem - - val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}" - googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString) - } catch (e: Exception) { - _errorFlow.emit("Invalid URL.") - } - } - } - - private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { - try { - val inputStream = application.contentResolver.openInputStream(uri) - val directory = File(application.filesDir, "map_layers") - if (!directory.exists()) { - directory.mkdirs() - } - val outputFile = File(directory, fileName) - val outputStream = FileOutputStream(outputFile) - - inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } } - Uri.fromFile(outputFile) - } catch (e: IOException) { - Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" } - null - } - } - - fun toggleLayerVisibility(layerId: String) { - var toggledLayer: MapLayerItem? = null - val updatedLayers = - _mapLayers.value.map { - if (it.id == layerId) { - toggledLayer = it.copy(isVisible = !it.isVisible) - toggledLayer - } else { - it - } - } - _mapLayers.value = updatedLayers - - toggledLayer?.let { - if (it.isVisible) { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString()) - } else { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString()) - } - } - } - - fun removeMapLayer(layerId: String) { - viewModelScope.launch { - val layerToRemove = _mapLayers.value.find { it.id == layerId } - layerToRemove?.uri?.let { uri -> - if (layerToRemove.isNetwork) { - googleMapsPrefs.setNetworkMapLayers( - googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(), - ) - } else { - deleteFileToInternalStorage(uri) - } - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString()) - } - _mapLayers.value = _mapLayers.value.filterNot { it.id == layerId } - } - } - - fun refreshMapLayer(layerId: String) { - viewModelScope.launch { - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } } - // By resetting the layer data in the UI (implied by just refreshing), - // we trigger a reload in the Composable. - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } } - } - } - - fun refreshAllVisibleNetworkLayers() { - _mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) } - } - - private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(dispatchers.io) { - try { - val file = uri.toFile() - if (file.exists()) { - file.delete() - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" } - } - } - } - - @Suppress("Recycle") - suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { - val uriToLoad = layerItem.uri ?: return null - return withContext(dispatchers.io) { - try { - if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val response = httpClient.get(uriToLoad.toString()) - if (!response.status.isSuccess()) { - Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" } - return@withContext null - } - response.bodyAsChannel().toInputStream() - } else { - application.contentResolver.openInputStream(uriToLoad) - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" } - null - } - } - } - - override fun onCleared() { - super.onCleared() - (currentTileProvider as? MBTilesProvider)?.close() - } - - override fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) -} - -enum class LayerType { - KML, - GEOJSON, -} - -data class MapLayerItem( - val id: String = Uuid.random().toString(), - val name: String, - val uri: Uri? = null, - val isVisible: Boolean = true, - val layerType: LayerType, - val isNetwork: Boolean = false, - val isRefreshing: Boolean = false, -) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt deleted file mode 100644 index 5c5e325ac..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.nodes_at_this_location -import org.meshtastic.core.resources.okay -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun ClusterItemsListDialog( - items: List, - onDismiss: () -> Unit, - onItemClick: (NodeClusterItem) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(Res.string.nodes_at_this_location)) }, - text = { - // Use a LazyColumn for potentially long lists of items - LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) { - items(items, key = { it.node.num }) { item -> - ClusterDialogListItem(item = item, onClick = { onItemClick(item) }) - } - } - }, - confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.okay)) } }, - ) -} - -@Composable -private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) { - ListItem( - leadingContent = { NodeChip(node = item.node) }, - headlineContent = { Text(item.nodeTitle) }, - supportingContent = { - if (item.nodeSnippet.isNotBlank()) { - Text(item.nodeSnippet) - } - }, - modifier = - modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt deleted file mode 100644 index fd9272579..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapLayerItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_layer -import org.meshtastic.core.resources.add_network_layer -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.hide_layer -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_layer_formats -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.network_layer_url_hint -import org.meshtastic.core.resources.no_map_layers_loaded -import org.meshtastic.core.resources.refresh -import org.meshtastic.core.resources.remove_layer -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.show_layer -import org.meshtastic.core.resources.url -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff - -@Suppress("LongMethod") -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun CustomMapLayersSheet( - mapLayers: List, - onToggleVisibility: (String) -> Unit, - onRemoveLayer: (String) -> Unit, - onAddLayerClicked: () -> Unit, - onRefreshLayer: (String) -> Unit, - onAddNetworkLayer: (String, String) -> Unit, -) { - var showAddNetworkLayerDialog by remember { mutableStateOf(false) } - LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.manage_map_layers), - style = MaterialTheme.typography.headlineSmall, - ) - HorizontalDivider() - } - item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), - text = stringResource(Res.string.map_layer_formats), - style = MaterialTheme.typography.bodySmall, - ) - } - - if (mapLayers.isEmpty()) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.no_map_layers_loaded), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(mapLayers, key = { it.id }) { layer -> - ListItem( - headlineContent = { Text(layer.name) }, - trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (layer.isNetwork) { - if (layer.isRefreshing) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(4.dp), - strokeWidth = 2.dp, - ) - } else { - IconButton(onClick = { onRefreshLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Refresh, - contentDescription = stringResource(Res.string.refresh), - ) - } - } - } - IconToggleButton( - checked = layer.isVisible, - onCheckedChange = { onToggleVisibility(layer.id) }, - ) { - Icon( - imageVector = - if (layer.isVisible) { - MeshtasticIcons.Visibility - } else { - MeshtasticIcons.VisibilityOff - }, - contentDescription = - stringResource( - if (layer.isVisible) { - Res.string.hide_layer - } else { - Res.string.show_layer - }, - ), - ) - } - IconButton(onClick = { onRemoveLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.remove_layer), - ) - } - } - }, - ) - HorizontalDivider() - } - } - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) { - Text(stringResource(Res.string.add_layer)) - } - Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) { - Text(stringResource(Res.string.add_network_layer)) - } - } - } - } - - if (showAddNetworkLayerDialog) { - AddNetworkLayerDialog( - onDismiss = { showAddNetworkLayerDialog = false }, - onConfirm = { name, url -> - onAddNetworkLayer(name, url) - showAddNetworkLayerDialog = false - }, - ) - } -} - -@Composable -fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { - var name by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } - - MeshtasticDialog( - onDismiss = onDismiss, - title = stringResource(Res.string.add_network_layer), - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(stringResource(Res.string.name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = url, - onValueChange = { url = it }, - label = { Text(stringResource(Res.string.url)) }, - placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - onConfirm = { onConfirm(name, url) }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt deleted file mode 100644 index 8082e40d1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.collectLatest -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_custom_tile_source -import org.meshtastic.core.resources.add_local_mbtiles_file -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.delete_custom_tile_source -import org.meshtastic.core.resources.edit_custom_tile_source -import org.meshtastic.core.resources.local_mbtiles_file -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.name_cannot_be_empty -import org.meshtastic.core.resources.no_custom_tile_sources_found -import org.meshtastic.core.resources.provider_name_exists -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.url_cannot_be_empty -import org.meshtastic.core.resources.url_must_contain_placeholders -import org.meshtastic.core.resources.url_template -import org.meshtastic.core.resources.url_template_hint -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.Edit -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.showToast - -@Suppress("LongMethod") -@Composable -fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) { - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - var editingConfig by remember { mutableStateOf(null) } - var showEditDialog by remember { mutableStateOf(false) } - val context = LocalContext.current - - val mbtilesPickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - val baseName = fileName.substringBeforeLast('.') - mapViewModel.addCustomTileProvider( - name = baseName, - urlTemplate = "", // Empty for local - localUri = uri.toString(), - ) - } - } - } - - LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } } - - if (showEditDialog) { - AddEditCustomTileProviderDialog( - config = editingConfig, - onDismiss = { showEditDialog = false }, - onSave = { name, url -> - if (editingConfig == null) { // Adding new - mapViewModel.addCustomTileProvider(name, url) - } else { // Editing existing - mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url)) - } - showEditDialog = false - }, - mapViewModel = mapViewModel, - ) - } - - LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - item { - Text( - text = stringResource(Res.string.manage_custom_tile_sources), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(16.dp), - ) - HorizontalDivider() - } - - if (customTileProviders.isEmpty()) { - item { - Text( - text = stringResource(Res.string.no_custom_tile_sources_found), - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(customTileProviders, key = { it.id }) { config -> - ListItem( - headlineContent = { Text(config.name) }, - supportingContent = { - if (config.isLocal) { - Text( - stringResource(Res.string.local_mbtiles_file), - style = MaterialTheme.typography.bodySmall, - ) - } else { - Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) - } - }, - trailingContent = { - Row { - IconButton( - onClick = { - editingConfig = config - showEditDialog = true - }, - ) { - Icon( - MeshtasticIcons.Edit, - contentDescription = stringResource(Res.string.edit_custom_tile_source), - ) - } - IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) { - Icon( - MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.delete_custom_tile_source), - ) - } - } - }, - ) - HorizontalDivider() - } - } - - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - editingConfig = null - showEditDialog = true - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_custom_tile_source)) - } - - Button( - onClick = { - val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - mbtilesPickerLauncher.launch(intent) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_local_mbtiles_file)) - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun AddEditCustomTileProviderDialog( - config: CustomTileProviderConfig?, - onDismiss: () -> Unit, - onSave: (String, String) -> Unit, - mapViewModel: MapViewModel, -) { - var name by rememberSaveable { mutableStateOf(config?.name ?: "") } - var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") } - var nameError by remember { mutableStateOf(null) } - var urlError by remember { mutableStateOf(null) } - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - - val emptyNameError = stringResource(Res.string.name_cannot_be_empty) - val providerNameExistsError = stringResource(Res.string.provider_name_exists) - val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty) - val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders) - - fun validateAndSave() { - val currentNameError = - validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError) - val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError) - - nameError = currentNameError - urlError = currentUrlError - - if (currentNameError == null && currentUrlError == null) { - onSave(name, url) - } - } - - MeshtasticDialog( - onDismiss = onDismiss, - title = - if (config == null) { - stringResource(Res.string.add_custom_tile_source) - } else { - stringResource(Res.string.edit_custom_tile_source) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { - name = it - nameError = null - }, - label = { Text(stringResource(Res.string.name)) }, - isError = nameError != null, - supportingText = { nameError?.let { Text(it) } }, - singleLine = true, - ) - OutlinedTextField( - value = url, - onValueChange = { - url = it - urlError = null - }, - label = { Text(stringResource(Res.string.url_template)) }, - isError = urlError != null, - supportingText = { - if (urlError != null) { - Text(urlError!!) - } else { - Text(stringResource(Res.string.url_template_hint)) - } - }, - singleLine = false, - maxLines = 2, - ) - } - }, - onConfirm = { validateAndSave() }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} - -private fun validateName( - name: String, - providers: List, - currentId: String?, - emptyNameError: String, - nameExistsError: String, -): String? = if (name.isBlank()) { - emptyNameError -} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) { - nameExistsError -} else { - null -} - -private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? = - if (url.isBlank()) { - emptyUrlError - } else if ( - !url.contains("{z}", ignoreCase = true) || - !url.contains("{x}", ignoreCase = true) || - !url.contains("{y}", ignoreCase = true) - ) { - mustContainPlaceholdersError - } else { - null - } - -private fun android.net.Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "mbtiles_file" - if (this.scheme == "content") { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1) { - name = cursor.getString(displayNameIndex) - } - } - } - } - return name -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index 18eb0ac83..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import android.app.DatePickerDialog -import android.app.TimePickerDialog -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDate -import kotlinx.datetime.Month -import kotlinx.datetime.atTime -import kotlinx.datetime.number -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours - -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") -@Composable -fun EditWaypointDialog( - waypoint: Waypoint, - onSendClicked: (Waypoint) -> Unit, - onDeleteClicked: (Waypoint) -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - var waypointInput by remember { mutableStateOf(waypoint) } - val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit - val defaultEmoji = 0x1F4CD // 📍 Round Pushpin - val currentEmojiCodepoint = if ((waypointInput.icon ?: 0) == 0) defaultEmoji else waypointInput.icon!! - var showEmojiPickerView by remember { mutableStateOf(false) } - - val context = LocalContext.current - val tz = systemTimeZone - - // Initialize date and time states from waypointInput.expire - var selectedDateString by remember { mutableStateOf("") } - var selectedTimeString by remember { mutableStateOf("") } - var isExpiryEnabled by remember { - mutableStateOf((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) - } - - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - dateFormat.timeZone = java.util.TimeZone.getDefault() - timeFormat.timeZone = java.util.TimeZone.getDefault() - - LaunchedEffect(waypointInput.expire, isExpiryEnabled) { - val expireValue = waypointInput.expire ?: 0 - if (isExpiryEnabled) { - if (expireValue != 0 && expireValue != Int.MAX_VALUE) { - val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong()) - val date = java.util.Date(instant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - } else { // If enabled but not set, default to 8 hours from now - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - val date = java.util.Date(futureInstant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) - } - } else { - selectedDateString = "" - selectedTimeString = "" - } - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Text( - text = stringResource(title), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - }, - text = { - Column(modifier = modifier.fillMaxWidth()) { - OutlinedTextField( - value = waypointInput.name ?: "", - onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) }, - label = { Text(stringResource(Res.string.name)) }, - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(currentEmojiCodepoint)), - modifier = - Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape) - .padding(6.dp), - fontSize = 20.sp, - ) - } - }, - ) - Spacer(modifier = Modifier.size(8.dp)) - OutlinedTextField( - value = waypointInput.description ?: "", - onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) }, - label = { Text(stringResource(Res.string.description)) }, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }), - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 3, - ) - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.locked)) - } - Switch( - checked = (waypointInput.locked_to ?: 0) != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.expires)) - } - Switch( - checked = isExpiryEnabled, - onCheckedChange = { checked -> - isExpiryEnabled = checked - if (checked) { - val expireValue = waypointInput.expire ?: 0 - // Default to 8 hours from now if not already set - if (expireValue == 0 || expireValue == Int.MAX_VALUE) { - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) - } - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (isExpiryEnabled) { - val currentInstant = - (waypointInput.expire ?: 0).let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - val ldt = currentInstant.toLocalDateTime(tz) - - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val currentLdt = - (waypointInput.expire ?: 0) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - ) - .atTime( - hour = currentLdt.hour, - minute = currentLdt.minute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.number - 1, - ldt.day, - ) - - val timePickerDialog = - TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val currentLdt = - (waypointInput.expire ?: 0) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = currentLdt.year, - month = currentLdt.month, - day = currentLdt.day, - ) - .atTime( - hour = selectedHour, - minute = selectedMinute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - android.text.format.DateFormat.is24HourFormat(context), - ) - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDateString, - style = MaterialTheme.typography.bodyMedium, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTimeString, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - } - }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - if (waypoint.id != 0) { - TextButton( - onClick = { onDeleteClicked(waypointInput) }, - modifier = Modifier.padding(end = 8.dp), - ) { - Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) - } - } - Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right - TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) { - Text(stringResource(Res.string.cancel)) - } - Button( - onClick = { onSendClicked(waypointInput) }, - enabled = (waypointInput.name ?: "").isNotBlank(), - ) { - Text(stringResource(Res.string.send)) - } - } - }, - dismissButton = null, // Using custom buttons in confirmButton Row - modifier = modifier, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji -> - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0)) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt deleted file mode 100644 index d8e29120e..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.feature.map.LastHeardFilter -import kotlin.math.roundToInt - -@Composable -internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.only_favorites)) }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = stringResource(Res.string.only_favorites), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_waypoints)) }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = stringResource(Res.string.show_waypoints), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_precision_circle)) }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = stringResource(Res.string.show_precision_circle), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - }, - ) - HorizontalDivider() - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} - -@Composable -internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.lastHeardTrackFilter.label), - ), - style = MaterialTheme.typography.labelLarge, - ) - Slider( - value = sliderPosition, - onValueChange = { sliderPosition = it }, - onValueChangeFinished = { - val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) - mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) - }, - valueRange = 0f..(filterOptions.size - 1).toFloat(), - steps = filterOptions.size - 2, - ) - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt deleted file mode 100644 index ad4bd58bb..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.maps.android.compose.MapType -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.map_type_hybrid -import org.meshtastic.core.resources.map_type_normal -import org.meshtastic.core.resources.map_type_satellite -import org.meshtastic.core.resources.map_type_terrain -import org.meshtastic.core.resources.selected_map_type -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Suppress("LongMethod") -@Composable -internal fun MapTypeDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - mapViewModel: MapViewModel, - onManageCustomTileProvidersClicked: () -> Unit, -) { - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle() - val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle() - - val googleMapTypes = - listOf( - stringResource(Res.string.map_type_normal) to MapType.NORMAL, - stringResource(Res.string.map_type_satellite) to MapType.SATELLITE, - stringResource(Res.string.map_type_terrain) to MapType.TERRAIN, - stringResource(Res.string.map_type_hybrid) to MapType.HYBRID, - ) - - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - googleMapTypes.forEach { (name, type) -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { - mapViewModel.setSelectedGoogleMapType(type) - onDismissRequest() // Close menu - }, - trailingIcon = - if (selectedCustomUrl == null && selectedGoogleMapType == type) { - { - Icon( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.string.selected_map_type), - ) - } - } else { - null - }, - ) - } - - if (customTileProviders.isNotEmpty()) { - HorizontalDivider() - customTileProviders.forEach { config -> - DropdownMenuItem( - text = { Text(config.name) }, - onClick = { - mapViewModel.selectCustomTileProvider(config) - onDismissRequest() // Close menu - }, - trailingIcon = - if (selectedCustomUrl == config.urlTemplate) { - { - Icon( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.string.selected_map_type), - ) - } - } else { - null - }, - ) - } - } - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(Res.string.manage_custom_tile_sources)) }, - onClick = { - onManageCustomTileProvidersClicked() - onDismissRequest() - }, - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt deleted file mode 100644 index 32e250475..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.google.maps.android.clustering.Cluster -import com.google.maps.android.clustering.view.DefaultClusterRenderer -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.clustering.Clustering -import com.google.maps.android.compose.clustering.ClusteringMarkerProperties -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.feature.map.BaseMapViewModel - -@OptIn(MapsComposeExperimentalApi::class) -@Suppress("NestedBlockDepth") -@Composable -fun NodeClusterMarkers( - nodeClusterItems: List, - mapFilterState: BaseMapViewModel.MapFilterState, - navigateToNodeDetails: (Int) -> Unit, - onClusterClick: (Cluster) -> Boolean, -) { - val view = LocalView.current - val lifecycleOwner = LocalLifecycleOwner.current - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - - // Workaround for https://github.com/googlemaps/android-maps-compose/issues/858 - // The maps clustering library creates an internal ComposeView to snapshot markers. - // If that view is not attached to the hierarchy (which it often isn't during rendering), - // it fails to find the Lifecycle and SavedState owners. We propagate them to the root view - // so the internal snapshot view can find them when walking up the tree. - LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) { - val root = view.rootView - if (root.findViewTreeLifecycleOwner() == null) { - root.setViewTreeLifecycleOwner(lifecycleOwner) - } - if (root.findViewTreeSavedStateRegistryOwner() == null) { - root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - } - } - - Clustering( - items = nodeClusterItems, - onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> - navigateToNodeDetails(item.node.num) - false - }, - clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, - onClusterManager = { clusterManager -> - (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 - }, - clusterItemDecoration = { clusterItem -> - if (mapFilterState.showPrecisionCircle) { - clusterItem.getPrecisionMeters()?.let { precisionMeters -> - if (precisionMeters > 0) { - Circle( - center = clusterItem.position, - radius = precisionMeters, - fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(clusterItem.node.colors.second), - strokeWidth = 2f, - zIndex = 0f, - ) - } - } - } - // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others) - ClusteringMarkerProperties(zIndex = clusterItem.getZIndex()) - }, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt deleted file mode 100644 index 5403b8c11..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) { - val animatedProgress = remember { Animatable(0f) } - - LaunchedEffect(node) { - if ((nowSeconds - node.lastHeard) <= 5) { - launch { - animatedProgress.snapTo(0f) - animatedProgress.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = 1000, easing = LinearEasing), - ) - } - } - } - - Box( - modifier = - modifier.drawWithContent { - drawContent() - if (animatedProgress.value > 0 && animatedProgress.value < 1f) { - val alpha = (1f - animatedProgress.value) * 0.3f - drawRoundRect( - size = size, - cornerRadius = CornerRadius(8.dp.toPx()), - color = Color.White.copy(alpha = alpha), - ) - } - }, - ) { - NodeChip(node = node) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt deleted file mode 100644 index 61cdab9f1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.Marker -import com.google.maps.android.compose.rememberComposeBitmapDescriptor -import com.google.maps.android.compose.rememberUpdatedMarkerState -import kotlinx.coroutines.launch -import org.meshtastic.app.map.convertIntToEmoji -import org.meshtastic.core.model.util.GeoConstants.DEG_D -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.locked -import org.meshtastic.core.ui.util.showToast -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.Waypoint - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -fun WaypointMarkers( - displayableWaypoints: List, - mapFilterState: BaseMapViewModel.MapFilterState, - myNodeNum: Int, - isConnected: Boolean, - onEditWaypointRequest: (Waypoint) -> Unit, - selectedWaypointId: Int? = null, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - if (mapFilterState.showWaypoints) { - displayableWaypoints.forEach { waypoint -> - val markerState = - rememberUpdatedMarkerState( - position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D), - ) - - LaunchedEffect(selectedWaypointId) { - if (selectedWaypointId == waypoint.id) { - markerState.showInfoWindow() - } - } - - val iconCodePoint = if ((waypoint.icon ?: 0) == 0) PUSHPIN else waypoint.icon!! - val emojiText = convertIntToEmoji(iconCodePoint) - val icon = - rememberComposeBitmapDescriptor(iconCodePoint) { - Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) - } - - Marker( - state = markerState, - icon = icon, - title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '), - snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '), - visible = true, - onInfoWindowClick = { - if ((waypoint.locked_to ?: 0) == 0 || waypoint.locked_to == myNodeNum || !isConnected) { - onEditWaypointRequest(waypoint) - } else { - scope.launch { context.showToast(Res.string.locked) } - } - }, - ) - } - } -} - -private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt deleted file mode 100644 index a28b3b6c1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import kotlinx.serialization.Serializable -import kotlin.uuid.Uuid - -@Serializable -data class CustomTileProviderConfig( - val id: String = Uuid.random().toString(), - val name: String, - val urlTemplate: String, - val localUri: String? = null, -) { - val isLocal: Boolean - get() = localUri != null -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index 4adb7d97d..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -class CustomTileSource { - - companion object { - fun getTileSource(index: Int) { - index - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt deleted file mode 100644 index 943d2c826..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model - -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.clustering.ClusterItem -import org.meshtastic.core.model.Node - -data class NodeClusterItem( - val node: Node, - val nodePosition: LatLng, - val nodeTitle: String, - val nodeSnippet: String, - val myNodeNum: Int? = null, -) : ClusterItem { - override fun getPosition(): LatLng = nodePosition - - override fun getTitle(): String = nodeTitle - - override fun getSnippet(): String = nodeSnippet - - override fun getZIndex(): Float = when { - node.num == myNodeNum -> 5.0f // My node is always highest - node.isFavorite -> 5.0f // Favorites are equally high priority - else -> 4.0f - } - - fun getPrecisionMeters(): Double? { - val precisionMap = - mapOf( - 10 to 23345.484932, - 11 to 11672.7369, - 12 to 5836.36288, - 13 to 2918.175876, - 14 to 1459.0823719999053, - 15 to 729.53562, - 16 to 364.7622, - 17 to 182.375556, - 18 to 91.182212, - 19 to 45.58554, - ) - return precisionMap[this.node.position.precision_bits ?: 0] - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt deleted file mode 100644 index fa17fedbf..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.node.NodeMapViewModel - -@Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - - Scaffold( - topBar = { - MainAppBar( - title = node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = {}, - onClickChip = {}, - ) - }, - ) { paddingValues -> - MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), - ) - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 2f7244b97..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.node - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a - * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode, - * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track - * filter). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - val focusedNode by vm.node.collectAsStateWithLifecycle() - MapView( - modifier = modifier, - mode = - GoogleMapMode.NodeTrack( - focusedNode = focusedNode, - positions = positions, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ), - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt deleted file mode 100644 index 668dedbaa..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.prefs.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.SharedPreferencesMigration -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Module -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers - -@Module -@ComponentScan("org.meshtastic.app.map") -class GoogleMapsKoinModule { - - @Single - @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = - PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(dispatchers.io + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt deleted file mode 100644 index 6cf6091b1..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.prefs.map - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.google.maps.android.compose.MapType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers - -/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */ -interface GoogleMapsPrefs { - val selectedGoogleMapType: StateFlow - - fun setSelectedGoogleMapType(value: String?) - - val selectedCustomTileUrl: StateFlow - - fun setSelectedCustomTileUrl(value: String?) - - val hiddenLayerUrls: StateFlow> - - fun setHiddenLayerUrls(value: Set) - - val cameraTargetLat: StateFlow - - fun setCameraTargetLat(value: Double) - - val cameraTargetLng: StateFlow - - fun setCameraTargetLng(value: Double) - - val cameraZoom: StateFlow - - fun setCameraZoom(value: Float) - - val cameraTilt: StateFlow - - fun setCameraTilt(value: Float) - - val cameraBearing: StateFlow - - fun setCameraBearing(value: Float) - - val networkMapLayers: StateFlow> - - fun setNetworkMapLayers(value: Set) -} - -@Single -class GoogleMapsPrefsImpl( - @Named("GoogleMapsDataStore") private val dataStore: DataStore, - dispatchers: CoroutineDispatchers, -) : GoogleMapsPrefs { - private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - - override val selectedGoogleMapType: StateFlow = - dataStore.data - .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name } - .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name) - - override fun setSelectedGoogleMapType(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF) - } else { - prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value - } - } - } - } - - override val selectedCustomTileUrl: StateFlow = - dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) - - override fun setSelectedCustomTileUrl(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF) - } else { - prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value - } - } - } - } - - override val hiddenLayerUrls: StateFlow> = - dataStore.data - .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setHiddenLayerUrls(value: Set) { - scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } } - } - - override val cameraTargetLat: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLat(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } } - } - - override val cameraTargetLng: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLng(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } } - } - - override val cameraZoom: StateFlow = - dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f) - - override fun setCameraZoom(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } } - } - - override val cameraTilt: StateFlow = - dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraTilt(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } } - } - - override val cameraBearing: StateFlow = - dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraBearing(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } } - } - - override val networkMapLayers: StateFlow> = - dataStore.data - .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setNetworkMapLayers(value: Set) { - scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } } - } - - companion object { - val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type") - val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url") - val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls") - val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat") - val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng") - val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom") - val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt") - val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing") - val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers") - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt deleted file mode 100644 index 6840cb17d..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.repository - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import org.koin.core.annotation.Single -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MapTileProviderPrefs - -interface CustomTileProviderRepository { - fun getCustomTileProviders(): Flow> - - suspend fun addCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun deleteCustomTileProvider(configId: String) - - suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? -} - -@Single -class CustomTileProviderRepositoryImpl( - private val json: Json, - private val dispatchers: CoroutineDispatchers, - private val mapTileProviderPrefs: MapTileProviderPrefs, -) : CustomTileProviderRepository { - - private val customTileProvidersStateFlow = MutableStateFlow>(emptyList()) - - init { - loadDataFromPrefs() - } - - override fun getCustomTileProviders(): Flow> = - customTileProvidersStateFlow.asStateFlow() - - override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value + config - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun deleteCustomTileProvider(configId: String) { - val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? = - customTileProvidersStateFlow.value.find { it.id == configId } - - private fun loadDataFromPrefs() { - val jsonString = mapTileProviderPrefs.customTileProviders.value - if (jsonString != null) { - try { - customTileProvidersStateFlow.value = json.decodeFromString>(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error deserializing tile providers" } - customTileProvidersStateFlow.value = emptyList() - } - } else { - customTileProvidersStateFlow.value = emptyList() - } - } - - private suspend fun saveDataToPrefs(providers: List) { - withContext(dispatchers.io) { - try { - val jsonString = json.encodeToString(providers) - mapTileProviderPrefs.setCustomTileProviders(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error serializing tile providers" } - } - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index d725537c8..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.traceroute - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.meshtastic.app.map.GoogleMapMode -import org.meshtastic.app.map.MapView -import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.proto.Position - -/** - * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute] - * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay). - */ -@Composable -fun TracerouteMap( - tracerouteOverlay: TracerouteOverlay?, - tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, - modifier: Modifier = Modifier, -) { - MapView( - modifier = modifier, - mode = - GoogleMapMode.Traceroute( - overlay = tracerouteOverlay, - nodePositions = tracerouteNodePositions, - onMappableCountChanged = onMappableCountChanged, - ), - ) -} diff --git a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index c86e7a78c..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.component - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.ComposeMapColorScheme -import com.google.maps.android.compose.GoogleMap -import com.google.maps.android.compose.MapUiSettings -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.MarkerComposable -import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberUpdatedMarkerState -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.core.ui.component.precisionBitsToMeters - -private const val DEFAULT_ZOOM = 15f - -@OptIn(MapsComposeExperimentalApi::class) -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val dark = isSystemInDarkTheme() - val mapColorScheme = - when (dark) { - true -> ComposeMapColorScheme.DARK - else -> ComposeMapColorScheme.LIGHT - } - key(node.num) { - val location = LatLng(node.latitude, node.longitude) - val cameraState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM) - } - - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = modifier, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = false, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = false, - scrollGesturesEnabled = false, - tiltGesturesEnabled = false, - zoomGesturesEnabled = false, - ), - cameraPositionState = cameraState, - ) { - val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0) - val latLng = LatLng(node.latitude, node.longitude) - if (precisionMeters > 0) { - Circle( - center = latLng, - radius = precisionMeters, - fillColor = Color(node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(node.colors.second), - strokeWidth = 2f, - ) - } - MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) } - } - } -} diff --git a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index 992edf588..000000000 --- a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomCenter, - overlayPadding = PaddingValues(bottom = 16.dp), - contentHorizontalAlignment = Alignment.CenterHorizontally, -) diff --git a/app/src/googleDebug/res/drawable-anydpi/ic_launcher_background.xml b/app/src/googleDebug/res/drawable-anydpi/ic_launcher_background.xml deleted file mode 100644 index 1a564c2ab..000000000 --- a/app/src/googleDebug/res/drawable-anydpi/ic_launcher_background.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/app/src/googleDebug/res/values/strings.xml b/app/src/googleDebug/res/values/strings.xml deleted file mode 100644 index dccab15c7..000000000 --- a/app/src/googleDebug/res/values/strings.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - Google Debug - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7d2ce900..5e08f29db 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,21 +1,4 @@ - - @@ -49,9 +32,6 @@ - - - - + - - - @@ -87,40 +64,24 @@ android:name="android.hardware.bluetooth_le" android:required="false" /> - - - - - - - - - - + - - + android:theme="@style/AppTheme" + android:localeConfig="@xml/locales_config"> - - - - - - - - - - - - + + @@ -174,72 +121,41 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -252,7 +168,7 @@ android:resource="@xml/device_filter" /> - @@ -276,20 +192,9 @@ android:path="com.geeksville.mesh" /> --> - - - - - - - - - + - - - - - diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl similarity index 71% rename from core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl rename to app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index f2307dd90..4efffd93b 100644 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -1,31 +1,24 @@ -package org.meshtastic.core.service; +// com.geeksville.mesh.IMeshService.aidl +package com.geeksville.mesh; // Declare any non-default types here with import statements -import org.meshtastic.core.model.DataPacket; -import org.meshtastic.core.model.NodeInfo; -import org.meshtastic.core.model.MeshUser; -import org.meshtastic.core.model.Position; -import org.meshtastic.core.model.MyNodeInfo; +parcelable DataPacket; +parcelable NodeInfo; +parcelable MeshUser; +parcelable Position; +parcelable MyNodeInfo; /** This is the public android API for talking to meshtastic radios. -@deprecated The AIDL service integration is deprecated and will be removed in a future release. - New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP). - Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK. - To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services -The intent you use to reach the service should ideally use the action string: - - val intent = Intent("com.geeksville.mesh.Service") - -Or if using an explicit intent: +The intent you use to reach the service should look like this: val intent = Intent().apply { setClassName( "com.geeksville.mesh", - "org.meshtastic.core.service.MeshService" + "com.geeksville.mesh.service.MeshService" ) } @@ -60,7 +53,7 @@ interface IMeshService { */ void setOwner(in MeshUser user); - void setRemoteOwner(in int requestId, in int destNum, in byte []payload); + void setRemoteOwner(in int requestId, in byte []payload); void getRemoteOwner(in int requestId, in int destNum); /// Return my unique user ID string @@ -117,10 +110,10 @@ interface IMeshService { void getRemoteChannel(in int requestId, in int destNum, in int channelIndex); /// Send beginEditSettings admin packet to nodeNum - void beginEditSettings(in int destNum); + void beginEditSettings(); /// Send commitEditSettings admin packet to nodeNum - void commitEditSettings(in int destNum); + void commitEditSettings(); /// delete a specific nodeNum from nodeDB void removeByNodenum(in int requestID, in int nodeNum); @@ -134,9 +127,6 @@ interface IMeshService { /// Send traceroute packet with wantResponse to nodeNum void requestTraceroute(in int requestId, in int destNum); - /// Send neighbor info packet with wantResponse to nodeNum - void requestNeighborInfo(in int requestId, in int destNum); - /// Send Shutdown admin packet to nodeNum void requestShutdown(in int requestId, in int destNum); @@ -146,11 +136,8 @@ interface IMeshService { /// Send FactoryReset admin packet to nodeNum void requestFactoryReset(in int requestId, in int destNum); - /// Send reboot to DFU admin packet - void rebootToDfu(in int destNum); - /// Send NodedbReset admin packet to nodeNum - void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites); + void requestNodedbReset(in int requestId, in int destNum); /// Returns a ChannelSet protobuf byte []getChannelSet(); @@ -160,27 +147,20 @@ interface IMeshService { */ String connectionState(); - /** - * @deprecated For internal use only. External callers must not invoke this method; - * it will be removed from the public API in a future release. - */ + /// If a macaddress we will try to talk to our device, if null we will be idle. + /// Any current connection will be dropped (even if the device address is the same) before reconnecting. + /// Users should not call this directly, only used internally by the MeshUtil activity + /// Returns true if the device address actually changed, or false if no change was needed boolean setDeviceAddress(String deviceAddr); /// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL /// if no my node info is available (i.e. it will not throw an exception) MyNodeInfo getMyNodeInfo(); - /** - * @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system. - * This method will be removed from the public API in a future release. - */ + /// Start updating the radios firmware void startFirmwareUpdate(); - /** - * @deprecated Always returns {@code -4}, which is outside the documented range. - * Firmware update progress is now tracked internally by the in-app OTA system. - * This method will be removed from the public API in a future release. - */ + /// Return a number 0-100 for firmware update progress. -1 for completed and success, -2 for failure int getUpdateStatus(); /// Start providing location (from phone GPS) to mesh @@ -191,17 +171,4 @@ interface IMeshService { /// Send request for node UserInfo void requestUserInfo(in int destNum); - - /// Request device connection status from the radio - void getDeviceConnectionStatus(in int requestId, in int destNum); - - /// Send request for telemetry to nodeNum - void requestTelemetry(in int requestId, in int destNum, in int type); - - /** - * Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only) - * mode is 1 for BLE, 2 for WiFi - * hash is the 32-byte firmware SHA256 hash (optional, can be null) - */ - void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash); } diff --git a/app/src/main/assets/device_bootloader_ota_quirks.json b/app/src/main/assets/device_bootloader_ota_quirks.json deleted file mode 100644 index 960c63101..000000000 --- a/app/src/main/assets/device_bootloader_ota_quirks.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "devices": [ - { - "hwModel": 18, - "hwModelSlug": "NANO_G2_ULTRA", - "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" - }, - { - "hwModel": 9, - "hwModelSlug": "RAK4631", - "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" - }, - { - "hwModel": 96, - "hwModelSlug": "NOMADSTAR_METEOR_PRO", - "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" - } - ] -} \ No newline at end of file diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json deleted file mode 100644 index b4e3550eb..000000000 --- a/app/src/main/assets/device_hardware.json +++ /dev/null @@ -1,1402 +0,0 @@ -[ - { - "hwModel": 1, - "hwModelSlug": "TLORA_V2", - "platformioTarget": "tlora-v2", - "architecture": "esp32", - "activelySupported": false, - "displayName": "LILYGO T-LoRa V2", - "tags": [ - "LilyGo" - ] - }, - { - "hwModel": 2, - "hwModelSlug": "TLORA_V1", - "platformioTarget": "tlora-v1", - "architecture": "esp32", - "activelySupported": false, - "displayName": "LILYGO T-LoRa V1", - "tags": [ - "LilyGo" - ] - }, - { - "hwModel": 3, - "hwModelSlug": "TLORA_V2_1_1P6", - "platformioTarget": "tlora-v2-1-1_6", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "LILYGO T-LoRa V2.1-1.6", - "tags": [ - "LilyGo" - ], - "images": [ - "tlora-v2-1-1_6.svg" - ] - }, - { - "hwModel": 4, - "hwModelSlug": "TBEAM", - "platformioTarget": "tbeam", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "LILYGO T-Beam", - "tags": [ - "LilyGo" - ], - "images": [ - "tbeam.svg" - ] - }, - { - "hwModel": 5, - "hwModelSlug": "HELTEC_V2_0", - "platformioTarget": "heltec-v2_0", - "architecture": "esp32", - "activelySupported": false, - "displayName": "Heltec V2.0", - "tags": [ - "Heltec" - ] - }, - { - "hwModel": 6, - "hwModelSlug": "TBEAM_V0P7", - "platformioTarget": "tbeam0_7", - "architecture": "esp32", - "activelySupported": false, - "displayName": "LILYGO T-Beam V0.7", - "tags": [ - "LilyGo" - ] - }, - { - "hwModel": 7, - "hwModelSlug": "T_ECHO", - "platformioTarget": "t-echo", - "architecture": "nrf52840", - "supportLevel": 1, - "activelySupported": true, - "displayName": "LILYGO T-Echo", - "tags": [ - "LilyGo" - ], - "images": [ - "t-echo.svg" - ], - "requiresDfu": true, - "hasInkHud": true - }, - { - "hwModel": 8, - "hwModelSlug": "TLORA_V1_1P3", - "platformioTarget": "tlora-v1_3", - "architecture": "esp32", - "activelySupported": false, - "displayName": "LILYGO T-LoRa V1.1-1.3", - "tags": [ - "LilyGo" - ] - }, - { - "hwModel": 9, - "hwModelSlug": "RAK4631", - "platformioTarget": "rak4631", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK WisBlock 4631", - "tags": [ - "RAK" - ], - "images": [ - "rak4631.svg", - "rak4631_case.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 10, - "hwModelSlug": "HELTEC_V2_1", - "platformioTarget": "heltec-v2_1", - "architecture": "esp32", - "activelySupported": false, - "displayName": "Heltec V2.1", - "tags": [ - "Heltec" - ] - }, - { - "hwModel": 11, - "hwModelSlug": "HELTEC_V1", - "platformioTarget": "heltec-v1", - "architecture": "esp32", - "activelySupported": false, - "displayName": "Heltec V1", - "tags": [ - "Heltec" - ] - }, - { - "hwModel": 12, - "hwModelSlug": "LILYGO_TBEAM_S3_CORE", - "platformioTarget": "tbeam-s3-core", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LILYGO T-Beam Supreme", - "tags": [ - "LilyGo" - ], - "images": [ - "tbeam-s3-core.svg" - ], - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 13, - "hwModelSlug": "RAK11200", - "platformioTarget": "rak11200", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "RAK WisBlock 11200", - "tags": [ - "RAK" - ], - "images": [ - "rak11200.svg" - ] - }, - { - "hwModel": 14, - "hwModelSlug": "NANO_G1", - "platformioTarget": "nano-g1", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Nano G1", - "tags": [ - "B&Q" - ] - }, - { - "hwModel": 15, - "hwModelSlug": "TLORA_V2_1_1P8", - "platformioTarget": "tlora-v2-1-1_8", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "LILYGO T-LoRa V2.1-1.8", - "tags": [ - "LilyGo", - "2.4GHz" - ], - "images": [ - "tlora-v2-1-1_8.svg" - ] - }, - { - "hwModel": 16, - "hwModelSlug": "TLORA_T3_S3", - "platformioTarget": "tlora-t3s3-v1", - "architecture": "esp32-s3", - "activelySupported": true, - "displayName": "LILYGO T-LoRa T3-S3", - "supportLevel": 1, - "tags": [ - "LilyGo" - ], - "images": [ - "tlora-t3s3-v1.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 16, - "hwModelSlug": "TLORA_T3_S3", - "platformioTarget": "tlora-t3s3-epaper", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LILYGO T-LoRa T3-S3 E-Ink", - "tags": [ - "LilyGo" - ], - "images": [ - "tlora-t3s3-epaper.svg" - ], - "requiresDfu": true, - "hasInkHud": true - }, - { - "hwModel": 17, - "hwModelSlug": "NANO_G1_EXPLORER", - "platformioTarget": "nano-g1-explorer", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Nano G1 Explorer", - "tags": [ - "B&Q" - ] - }, - { - "hwModel": 18, - "hwModelSlug": "NANO_G2_ULTRA", - "platformioTarget": "nano-g2-ultra", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 2, - "displayName": "Nano G2 Ultra", - "tags": [ - "B&Q" - ], - "requiresDfu": true, - "images": [ - "nano-g2-ultra.svg" - ] - }, - { - "hwModel": 21, - "hwModelSlug": "WIO_WM1110", - "platformioTarget": "wio-tracker-wm1110", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Seeed Wio WM1110 Tracker", - "tags": [ - "Seeed" - ], - "images": [ - "wio-tracker-wm1110.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 25, - "hwModelSlug": "STATION_G1", - "platformioTarget": "station-g1", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Station G1", - "tags": [ - "B&Q" - ] - }, - { - "hwModel": 26, - "hwModelSlug": "RAK11310", - "platformioTarget": "rak11310", - "architecture": "rp2040", - "activelySupported": true, - "supportLevel": 2, - "displayName": "RAK WisBlock 11310", - "tags": [ - "RAK" - ], - "images": [ - "rak11310.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 29, - "hwModelSlug": "CANARYONE", - "platformioTarget": "canaryone", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Canary One", - "tags": [ - "Canary" - ], - "requiresDfu": true - }, - { - "hwModel": 30, - "hwModelSlug": "RP2040_LORA", - "platformioTarget": "rp2040-lora", - "architecture": "rp2040", - "activelySupported": true, - "supportLevel": 2, - "displayName": "RP2040 LoRa", - "tags": [ - "Waveshare" - ], - "requiresDfu": true - }, - { - "hwModel": 31, - "hwModelSlug": "STATION_G2", - "platformioTarget": "station-g2", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 2, - "displayName": "Station G2", - "tags": [ - "B&Q" - ], - "requiresDfu": true, - "images": [ - "station-g2.svg" - ], - "partitionScheme": "16MB" - }, - { - "hwModel": 33, - "hwModelSlug": "T_ECHO_PLUS", - "platformioTarget": "t-echo-plus", - "architecture": "nrf52840", - "supportLevel": 1, - "activelySupported": true, - "displayName": "LILYGO T-Echo Plus", - "tags": [ - "LilyGo" - ], - "images": [ - "t-echo_plus.svg" - ], - "requiresDfu": true, - "hasInkHud": true - }, - { - "hwModel": 39, - "hwModelSlug": "DIY_V1", - "platformioTarget": "meshtastic-diy-v1", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "DIY V1", - "tags": [ - "DIY" - ], - "images": [ - "diy.svg" - ] - }, - { - "hwModel": 39, - "hwModelSlug": "HYDRA", - "platformioTarget": "hydra", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Hydra", - "tags": [ - "DIY" - ] - }, - { - "hwModel": 41, - "hwModelSlug": "DR_DEV", - "platformioTarget": "meshtastic-dr-dev", - "architecture": "esp32", - "activelySupported": false, - "displayName": "DR-DEV", - "tags": [ - "DIY" - ] - }, - { - "hwModel": 42, - "hwModelSlug": "M5STACK", - "platformioTarget": "m5stack-core", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 3, - "displayName": "M5 Stack", - "tags": [ - "M5Stack" - ] - }, - { - "hwModel": 43, - "hwModelSlug": "HELTEC_V3", - "platformioTarget": "heltec-v3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec V3", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-v3.svg", - "heltec-v3-case.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 44, - "hwModelSlug": "HELTEC_WSL_V3", - "platformioTarget": "heltec-wsl-v3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Wireless Stick Lite V3", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-wsl-v3.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 47, - "hwModelSlug": "RPI_PICO", - "platformioTarget": "pico", - "architecture": "rp2040", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Raspberry Pi Pico", - "tags": [ - "RPi", - "DIY" - ], - "requiresDfu": true, - "images": [ - "pico.svg" - ] - }, - { - "hwModel": 47, - "hwModelSlug": "RPI_PICO", - "platformioTarget": "picow", - "architecture": "rp2040", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Raspberry Pi Pico W", - "tags": [ - "RPi", - "DIY" - ], - "requiresDfu": true, - "images": [ - "rpipicow.svg" - ] - }, - { - "hwModel": 48, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER", - "platformioTarget": "heltec-wireless-tracker", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Wireless Tracker V1.1", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-wireless-tracker.svg" - ], - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 58, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER_V1_0", - "platformioTarget": "heltec-wireless-tracker-V1-0", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 3, - "displayName": "Heltec Wireless Tracker V1.0", - "images": [ - "heltec-wireless-tracker.svg" - ], - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 49, - "hwModelSlug": "HELTEC_WIRELESS_PAPER", - "platformioTarget": "heltec-wireless-paper", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Wireless Paper", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-wireless-paper.svg" - ], - "hasInkHud": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 50, - "hwModelSlug": "T_DECK", - "platformioTarget": "t-deck", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LILYGO T-Deck", - "tags": [ - "LilyGo" - ], - "images": [ - "t-deck.svg" - ], - "requiresDfu": true, - "hasMui": true, - "partitionScheme": "16MB" - }, - { - "hwModel": 51, - "hwModelSlug": "T_WATCH_S3", - "platformioTarget": "t-watch-s3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "LILYGO T-Watch S3", - "tags": [ - "LilyGo" - ], - "images": [ - "t-watch-s3.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 52, - "hwModelSlug": "PICOMPUTER_S3", - "platformioTarget": "picomputer-s3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Pi Computer S3", - "hasMui": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 53, - "hwModelSlug": "HELTEC_HT62", - "platformioTarget": "heltec-ht62-esp32c3-sx1262", - "architecture": "esp32-c3", - "supportLevel": 1, - "activelySupported": true, - "displayName": "Heltec HT62", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-ht62-esp32c3-sx1262.svg" - ] - }, - { - "hwModel": 57, - "hwModelSlug": "HELTEC_WIRELESS_PAPER_V1_0", - "platformioTarget": "heltec-wireless-paper-v1_0", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 3, - "tags": [ - "Heltec" - ], - "displayName": "Heltec Wireless Paper V1.0", - "images": [ - "heltec-wireless-paper-v1_0.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 59, - "hwModelSlug": "UNPHONE", - "platformioTarget": "unphone", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "unPhone", - "requiresDfu": true, - "hasMui": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 48, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER", - "platformioTarget": "tracksenger", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "TrackSenger (small TFT)", - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 48, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER", - "platformioTarget": "tracksenger-lcd", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 3, - "displayName": "TrackSenger (big TFT)", - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 48, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER", - "platformioTarget": "tracksenger-oled", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "TrackSenger (big OLED)", - "partitionScheme": "8MB" - }, - { - "hwModel": 61, - "hwModelSlug": "CDEBYTE_EORA_S3", - "platformioTarget": "CDEBYTE_EoRa-S3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "EBYTE EoRa-S3", - "tags": [ - "EByte" - ], - "requiresDfu": true - }, - { - "hwModel": 64, - "hwModelSlug": "RADIOMASTER_900_BANDIT_NANO", - "platformioTarget": "radiomaster_900_bandit_nano", - "architecture": "esp32", - "activelySupported": true, - "supportLevel": 2, - "displayName": "RadioMaster 900 Bandit Nano", - "tags": [ - "RadioMaster" - ] - }, - { - "hwModel": 66, - "hwModelSlug": "HELTEC_VISION_MASTER_T190", - "platformioTarget": "heltec-vision-master-t190", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Vision Master T190", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-vision-master-t190.svg" - ], - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 67, - "hwModelSlug": "HELTEC_VISION_MASTER_E213", - "platformioTarget": "heltec-vision-master-e213", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Vision Master E213", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-vision-master-e213.svg" - ], - "requiresDfu": true, - "hasInkHud": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 68, - "hwModelSlug": "HELTEC_VISION_MASTER_E290", - "platformioTarget": "heltec-vision-master-e290", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Vision Master E290", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-vision-master-e290.svg" - ], - "requiresDfu": true, - "hasInkHud": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 69, - "hwModelSlug": "HELTEC_MESH_NODE_T114", - "platformioTarget": "heltec-mesh-node-t114", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Mesh Node T114", - "tags": [ - "Heltec" - ], - "images": [ - "heltec-mesh-node-t114.svg", - "heltec-mesh-node-t114-case.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 70, - "hwModelSlug": "SENSECAP_INDICATOR", - "platformioTarget": "seeed-sensecap-indicator", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed SenseCAP Indicator", - "tags": [ - "Seeed" - ], - "images": [ - "seeed-sensecap-indicator.svg" - ], - "hasMui": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 71, - "hwModelSlug": "TRACKER_T1000_E", - "platformioTarget": "tracker-t1000-e", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed Card Tracker T1000-E", - "tags": [ - "Seeed" - ], - "images": [ - "tracker-t1000-e.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 81, - "hwModelSlug": "SEEED_XIAO_S3", - "platformioTarget": "seeed-xiao-s3", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 3, - "displayName": "Seeed Xiao ESP32-S3", - "tags": [ - "Seeed" - ], - "images": [ - "seeed-xiao-s3.svg" - ], - "requiresDfu": true, - "partitionScheme": "8MB" - }, - { - "hwModel": 105, - "hwModelSlug": "WISMESH_TAG", - "platformioTarget": "rak_wismeshtag", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK WisMesh Tag", - "tags": [ - "RAK" - ], - "images": [ - "rak_wismesh_tag.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 84, - "hwModelSlug": "WISMESH_TAP", - "platformioTarget": "rak_wismeshtap", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK WisMesh Tap", - "tags": [ - "RAK" - ], - "images": [ - "rak-wismeshtap.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 22, - "hwModelSlug": "WISMESH_HUB", - "platformioTarget": "rak2560", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK WisMesh Repeater", - "tags": [ - "RAK" - ], - "images": [ - "rak2560.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 63, - "hwModelSlug": "NRF52_PROMICRO_DIY", - "platformioTarget": "nrf52_promicro_diy_tcxo", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 3, - "displayName": "NRF52 Pro-micro DIY", - "tags": [ - "DIY" - ], - "images": [ - "promicro.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 88, - "hwModelSlug": "XIAO_NRF52_KIT", - "platformioTarget": "seeed_xiao_nrf52840_kit", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed Xiao NRF52840 Kit", - "tags": [ - "Seeed" - ], - "requiresDfu": true, - "images": [ - "seeed_xiao_nrf52_kit.svg" - ] - }, - { - "hwModel": 89, - "hwModelSlug": "THINKNODE_M1", - "platformioTarget": "thinknode_m1", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M1", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "thinknode_m1.svg" - ], - "hasInkHud": true - }, - { - "hwModel": 107, - "hwModelSlug": "THINKNODE_M5", - "platformioTarget": "thinknode_m5", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M5", - "tags": [ - "Elecrow" - ], - "requiresDfu": false, - "images": [ - "thinknode_m1.svg" - ], - "hasInkHud": true - }, - { - "hwModel": 90, - "hwModelSlug": "THINKNODE_M2", - "platformioTarget": "thinknode_m2", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M2", - "tags": [ - "Elecrow" - ], - "requiresDfu": false, - "images": [ - "thinknode_m2.svg" - ] - }, - { - "hwModel": 93, - "hwModelSlug": "MUZI_BASE", - "platformioTarget": "muzi-base", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "muzi BASE DUO/UNO", - "tags": [ - "muzi" - ], - "requiresDfu": true, - "images": [ - "muzi_base.svg" - ] - }, - { - "hwModel": 94, - "hwModelSlug": "HELTEC_MESH_POCKET", - "platformioTarget": "heltec-mesh-pocket-10000", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec MeshPocket", - "tags": [ - "Heltec" - ], - "images": [ - "heltec_mesh_pocket.svg" - ], - "requiresDfu": true, - "hasInkHud": true, - "key": "HELTEC_MESH_POCKET", - "variant": "10000mAh" - }, - { - "hwModel": 94, - "hwModelSlug": "HELTEC_MESH_POCKET", - "platformioTarget": "heltec-mesh-pocket-5000", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec MeshPocket", - "tags": [ - "Heltec" - ], - "images": [ - "heltec_mesh_pocket.svg" - ], - "requiresDfu": true, - "hasInkHud": true, - "key": "HELTEC_MESH_POCKET", - "variant": "5000mAh" - }, - { - "hwModel": 95, - "hwModelSlug": "SEEED_SOLAR_NODE", - "platformioTarget": "seeed_solar_node", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed SenseCAP Solar Node", - "tags": [ - "Seeed" - ], - "images": [ - "seeed_solar.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 99, - "hwModelSlug": "SEEED_WIO_TRACKER_L1", - "platformioTarget": "seeed_wio_tracker_L1", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed Wio Tracker L1", - "tags": [ - "Seeed" - ], - "images": [ - "wio_tracker_l1_case.svg" - ], - "requiresDfu": true - }, - { - "hwModel": 100, - "hwModelSlug": "SEEED_WIO_TRACKER_L1_EINK", - "platformioTarget": "seeed_wio_tracker_L1_eink", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Seeed Wio Tracker L1 E-Ink", - "tags": [ - "Seeed" - ], - "requiresDfu": true, - "hasInkHud": true, - "images": [ - "wio_tracker_l1_eink.svg" - ] - }, - { - "hwModel": 96, - "hwModelSlug": "NOMADSTAR_METEOR_PRO", - "platformioTarget": "rak4631_nomadstar_meteor_pro", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "NomadStar Meteor Pro", - "tags": [ - "NomadStar" - ], - "requiresDfu": true, - "images": [ - "meteor_pro.svg" - ] - }, - { - "hwModel": 97, - "hwModelSlug": "CROWPANEL", - "platformioTarget": "elecrow-adv1-43-50-70-tft", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Crowpanel Adv 4.3/5.0/7.0 TFT", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "crowpanel_5_0.svg", - "crowpanel_7_0.svg" - ], - "partitionScheme": "16MB", - "hasMui": true - }, - { - "hwModel": 97, - "hwModelSlug": "CROWPANEL", - "platformioTarget": "elecrow-adv-24-28-tft", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Crowpanel Adv 2.4/2.8 TFT", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "crowpanel_2_4.svg", - "crowpanel_2_8.svg" - ], - "partitionScheme": "16MB", - "hasMui": true - }, - { - "hwModel": 97, - "hwModelSlug": "CROWPANEL", - "platformioTarget": "elecrow-adv-35-tft", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Crowpanel Adv 3.5 TFT", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "crowpanel_3_5.svg" - ], - "partitionScheme": "16MB", - "hasMui": true - }, - { - "hwModel": 101, - "hwModelSlug": "MUZI_R1_NEO", - "platformioTarget": "r1-neo", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "muzi R1 Neo", - "tags": [ - "muzi" - ], - "requiresDfu": true, - "images": [ - "muzi_r1_neo.svg" - ] - }, - { - "hwModel": 102, - "hwModelSlug": "T_DECK_PRO", - "platformioTarget": "t-deck-pro", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LILYGO T-Deck Pro", - "tags": [ - "LilyGo" - ], - "images": [ - "tdeck_pro.svg" - ], - "requiresDfu": true, - "hasMui": false, - "partitionScheme": "16MB" - }, - { - "hwModel": 103, - "hwModelSlug": "T_LORA_PAGER", - "platformioTarget": "tlora-pager", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LILYGO T-LoRa Pager", - "tags": [ - "LilyGo" - ], - "requiresDfu": true, - "hasMui": false, - "partitionScheme": "16MB", - "images": [ - "lilygo-tlora-pager.svg" - ] - }, - { - "hwModel": 108, - "hwModelSlug": "HELTEC_MESH_SOLAR", - "platformioTarget": "heltec-mesh-solar", - "architecture": "nrf52840", - "activelySupported": false, - "supportLevel": 1, - "displayName": "Heltec MeshSolar", - "tags": [ - "Heltec" - ], - "requiresDfu": true, - "images": [ - "heltec-mesh-solar.svg" - ] - }, - { - "hwModel": 109, - "hwModelSlug": "T_ECHO_LITE", - "platformioTarget": "t-echo-lite", - "architecture": "nrf52840", - "activelySupported": false, - "supportLevel": 1, - "displayName": "LILYGO T-Echo Lite", - "tags": [ - "LilyGo" - ], - "requiresDfu": true, - "hasInkHud": false, - "images": [ - "techo_lite.svg" - ] - }, - { - "hwModel": 111, - "hwModelSlug": "M5STACK_C6L", - "platformioTarget": "m5stack-unitc6l", - "architecture": "esp32-c6", - "supportLevel": 1, - "activelySupported": true, - "displayName": "M5Stack Unit C6L", - "tags": [ - "M5Stack" - ], - "images": [ - "m5_c6l.svg" - ] - }, - { - "hwModel": 110, - "hwModelSlug": "HELTEC_V4", - "platformioTarget": "heltec-v4", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec V4", - "tags": [ - "Heltec" - ], - "requiresDfu": true, - "hasMui": true, - "partitionScheme": "16MB", - "images": [ - "heltec_v4.svg" - ] - }, - { - "hwModel": 106, - "hwModelSlug": "RAK3312", - "platformioTarget": "rak3312", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK3312", - "tags": [ - "RAK" - ], - "requiresDfu": false, - "hasMui": false, - "partitionScheme": "16MB", - "images": [ - "rak_3312.svg" - ] - }, - { - "hwModel": 112, - "hwModelSlug": "M5STACK_CARDPUTER_ADV", - "platformioTarget": "m5stack-cardputer-adv", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 1, - "displayName": "Cardputer Mesh Kit", - "tags": [ - "M5Stack" - ], - "images": [ - "m5stack_cardputer.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 113, - "hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2", - "platformioTarget": "heltec-wireless-tracker-v2", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "Heltec Wireless Tracker V2", - "tags": [ - "Heltec" - ], - "images": [ - "heltec_wireless_tracker_v2.svg" - ], - "partitionScheme": "8MB" - }, - { - "hwModel": 115, - "hwModelSlug": "THINKNODE_M3", - "platformioTarget": "thinknode_m3", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M3", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "thinknode_m3.svg" - ] - }, - { - "hwModel": 116, - "hwModelSlug": "WISMESH_TAP_V2", - "platformioTarget": "rak_wismesh_tap_v2", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK WisMesh Tap V2", - "tags": [ - "RAK" - ], - "hasMui": true, - "partitionScheme": "8MB", - "images": [ - "rak-wismesh-tap-v2.svg" - ] - }, - { - "hwModel": 117, - "hwModelSlug": "RAK3401", - "platformioTarget": "rak3401-1watt", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "RAK3401 1W", - "tags": [ - "RAK" - ], - "requiresDfu": true, - "images": [ - "rak3401.svg" - ] - }, - { - "hwModel": 119, - "hwModelSlug": "THINKNODE_M4", - "platformioTarget": "thinknode_m4", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M4", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "thinknode_m4.svg" - ] - }, - { - "hwModel": 120, - "hwModelSlug": "THINKNODE_M6", - "platformioTarget": "thinknode_m6", - "architecture": "nrf52840", - "activelySupported": true, - "supportLevel": 1, - "displayName": "ThinkNode M6", - "tags": [ - "Elecrow" - ], - "requiresDfu": true, - "images": [ - "thinknode_m6.svg" - ] - }, - { - "hwModel": 122, - "hwModelSlug": "TBEAM_1_WATT", - "platformioTarget": "t-beam-1w", - "architecture": "esp32-s3", - "activelySupported": true, - "supportLevel": 1, - "displayName": "LilyGo T-Beam 1W", - "tags": [ - "LilyGo" - ], - "hasMui": false, - "partitionScheme": "8MB", - "images": [ - "tbeam-1w.svg" - ] - }, - { - "hwModel": 123, - "hwModelSlug": "T5_S3_EPAPER_PRO", - "platformioTarget": "t5-epaper-s3", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 1, - "displayName": "LilyGo T5 E-paper S3 Pro", - "tags": [ - "LilyGo" - ], - "hasMui": false, - "partitionScheme": "8MB", - "images": [ - "t5s3_epaper.svg" - ] - }, - { - "hwModel": 125, - "hwModelSlug": "MINI_EPAPER_S3", - "platformioTarget": "mini-epaper-s3", - "architecture": "esp32-s3", - "activelySupported": false, - "supportLevel": 1, - "displayName": "LilyGo Mini E-paper S3", - "tags": [ - "LilyGo" - ], - "hasMui": false, - "images": [ - "mini-epaper-s3.svg" - ] - } -] \ No newline at end of file diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json deleted file mode 100644 index ffdb465d6..000000000 --- a/app/src/main/assets/firmware_releases.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "releases": { - "stable": [ - { - "id": "v2.7.15.567b8ea", - "title": "Meshtastic Firmware 2.7.15.567b8ea Beta", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.15.567b8ea", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.15.567b8ea/firmware-esp32-2.7.15.567b8ea.zip", - "release_notes": "> [!WARNING]\r\n> If you experience immediate bluetooth pairing failures or failure to fully boot after updating, this likely indicates that you need to perform a full erase and flash. Consider backing up your settings before updating.\r\n\r\n> [!IMPORTANT]\r\n> This release marks the end of legacy (non-private) DMs. Direct messages will only be allowed using PKI going forward.\r\n> This release also disables device telemetry broadcasts over the mesh by default. If you want to opt back in, you will need to re-enable this in the apps. \r\n\r\n## 🚀 What's Changed\r\n* Clean up GPS toggle logging by @jp-bennett in https://github.com/meshtastic/firmware/pull/8629\r\n* Reset the calibration data back to 0 when doing a compass calibration by @jp-bennett in https://github.com/meshtastic/firmware/pull/8648\r\n* Chore(deps): update dorny/test-reporter action to v2.2.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8637\r\n* Fix RPM builds by @vidplace7 in https://github.com/meshtastic/firmware/pull/8659\r\n* Linux: Fix silly EPEL9 mistake by @vidplace7 in https://github.com/meshtastic/firmware/pull/8660\r\n* Fix ble rssi crash by @thebentern in https://github.com/meshtastic/firmware/pull/8661\r\n* Mqtt: do not try to send packets when it disconnected by @omgbebebe in https://github.com/meshtastic/firmware/pull/8658\r\n* Persist favourites on NodeDB reset by @ford-jones in https://github.com/meshtastic/firmware/pull/8292\r\n* Don't ack messages when mqtt client proxy is on but only uplink by @RCGV1 in https://github.com/meshtastic/firmware/pull/8578\r\n* Add API types, state, and log message in Debug screen. Added persistent \"Connected\" icon by @jp-bennett in https://github.com/meshtastic/firmware/pull/8576\r\n* Drop PKI acks if there is no downlink on MQTTClientProxy by @RCGV1 in https://github.com/meshtastic/firmware/pull/8580\r\n* Add the Heltec v4 expansion box. by @Quency-D in https://github.com/meshtastic/firmware/pull/8539\r\n* Update to Pro-micro variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8600\r\n* Cleanup unnecessary global dereferencing in CryptoEngine by @jasonbcox in https://github.com/meshtastic/firmware/pull/8611\r\n* Fix null pointer dereference in radio chip region check by @Andrik45719 in https://github.com/meshtastic/firmware/pull/8613\r\n* Feat/6704 neighbor info on demand by @DaneEvans in https://github.com/meshtastic/firmware/pull/8523\r\n* Remove fixed scaling in Digital Clock by @Xaositek in https://github.com/meshtastic/firmware/pull/8620\r\n* Allow Preserving Favorites in BaseUI menus by @Xaositek in https://github.com/meshtastic/firmware/pull/8647\r\n* native: Try to look for a config file based on Raspberry Pi HAT vendor by @Stary2001 in https://github.com/meshtastic/firmware/pull/8608\r\n* Remove gating for Display Options by @Xaositek in https://github.com/meshtastic/firmware/pull/8651\r\n* mqtt: do not try to send packets when it disconnected by @omgbebebe in https://github.com/meshtastic/firmware/pull/8658\r\n\r\n## New Contributors\r\n* @weebl2000 made their first contribution in https://github.com/meshtastic/firmware/pull/8560\r\n* @omgbebebe made their first contribution in https://github.com/meshtastic/firmware/pull/8658\r\n* @viric made their first contribution in https://github.com/meshtastic/firmware/pull/7882\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.13.597fa0b...v2.7.15.567b8ea" - }, - { - "id": "v2.6.11.60ec05e", - "title": "Meshtastic Firmware 2.6.11.60ec05e Beta", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.11.60ec05e", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.11.60ec05e/firmware-esp32-2.6.11.60ec05e.zip", - "release_notes": "> [!CAUTION] \r\n> In older firmware, generated public/private keys may have insufficient entropy, resulting in the possibility of key reuse across devices. This release delays key generation until the user sets a LoRa region, and also mixes in additional sources of randomness. Additionally, if one of the known key collisions are detected, the user is notified, and should regenerate keys as soon as possible.\r\n\r\n## 🚀 Enhancements\r\n* Add --1200bps-reset param to device-install/update scripts by @ThatKalle in https://github.com/meshtastic/firmware/pull/6752\r\n* Generate keys when Lora Region is set by @jp-bennett in https://github.com/meshtastic/firmware/pull/6951\r\n* Seeed_xiao_nrf52840_kit improvements by @ndoo in https://github.com/meshtastic/firmware/pull/6930\r\n* Add InkHUD driver for WeAct Studio 2.9\" display module by @todd-herbert in https://github.com/meshtastic/firmware/pull/6963\r\n* [Variant] nomadstar meteor pro by @CypressXt in https://github.com/meshtastic/firmware/pull/6742\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* fix: Respect LED_STATE_ON for power and user LED by @ndoo in https://github.com/meshtastic/firmware/pull/6976\r\n* Chore(deps): update platformio/espressif32 to v6.11.0 by @renovate in https://github.com/meshtastic/firmware/pull/6900\r\n* Update Alpine to 3.22 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6927\r\n* Clean up install & update shell scripts by @roens in https://github.com/meshtastic/firmware/pull/6839\r\n* Addition of Device Role inside of userPrefs.jsonc by @Crank-Git in https://github.com/meshtastic/firmware/pull/6972\r\n* Chore(deps): update platformio/ststm32 to v19.2.0 by @renovate in https://github.com/meshtastic/firmware/pull/6901\r\n* Chore(deps): update meshtastic/device-ui digest to 2fd19f8 by @renovate in https://github.com/meshtastic/firmware/pull/6982\r\n* Add note to hydra to note that the button pin has no pull-up by @NomDeTom in https://github.com/meshtastic/firmware/pull/6979\r\n* Chore(deps): update meshtastic/device-ui digest to 1b520fc by @renovate in https://github.com/meshtastic/firmware/pull/6991\r\n* Update heltec t114 URL by @dieseltravis in https://github.com/meshtastic/firmware/pull/7004\r\n* Update URL for ThinkNode M1 by @dieseltravis in https://github.com/meshtastic/firmware/pull/7005\r\n* Improve support for Heltec Wireless Bridge by @berlincount in https://github.com/meshtastic/firmware/pull/6647\r\n* Warn users about low entropy keys by @jp-bennett in https://github.com/meshtastic/firmware/pull/7003\r\n* T-watch screen misalignment fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/6996\r\n* Fix for T-Deck Plus: disable touch IRQ / enable custom touch driver by @mverch67 in https://github.com/meshtastic/firmware/pull/6988\r\n* Create lora-piggystick-lr1121.yaml by @markbirss in https://github.com/meshtastic/firmware/pull/7010\r\n\r\n## New Contributors\r\n* @roens made their first contribution in https://github.com/meshtastic/firmware/pull/6839\r\n* @Crank-Git made their first contribution in https://github.com/meshtastic/firmware/pull/6972\r\n* @dieseltravis made their first contribution in https://github.com/meshtastic/firmware/pull/7004\r\n* @berlincount made their first contribution in https://github.com/meshtastic/firmware/pull/6647\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.10.9ce4455...v2.6.11.60ec05e" - }, - { - "id": "v2.6.10.9ce4455", - "title": "Meshtastic Firmware 2.6.10.9ce4455 Beta", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.10.9ce4455", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.10.9ce4455/firmware-esp32-2.6.10.9ce4455.zip", - "release_notes": "## 🚀 Enhancements\r\n* Don't cancel sending ReTx for relayer if we're ROUTER(_LATE)/REPEATER by @GUVWAF in https://github.com/meshtastic/firmware/pull/6904\r\n* Add LINK32 (Lilygo) Board with Light+Environment sensors by @caveman99 in https://github.com/meshtastic/firmware/pull/6899\r\n* Add support for seeed wio tracker L1 by @Dylanliacc in https://github.com/meshtastic/firmware/pull/6907\r\n* Added full support for LTR390UV readings of UV and Lux by @dmarman in https://github.com/meshtastic/firmware/pull/6872\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Linux: Adjust udev rules for gpio by @vidplace7 in https://github.com/meshtastic/firmware/pull/6891\r\n* Coerce user.id to always be derive from the nodenum by @thebentern in https://github.com/meshtastic/firmware/pull/6906\r\n* Parse own short name on InkHUD shutdown screen by @todd-herbert in https://github.com/meshtastic/firmware/pull/6913\r\n* Don't cancel sending ReTx for relayer if we're ROUTER(_LATE)/REPEATER by @GUVWAF in https://github.com/meshtastic/firmware/pull/6904\r\n* Fix renovate for Adafruit PCT2075 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6919\r\n* Update TSL2591 gain and timing by @ArgoNavi in https://github.com/meshtastic/firmware/pull/6921\r\n\r\n## New Contributors\r\n* @dmarman made their first contribution in https://github.com/meshtastic/firmware/pull/6872\r\n* @ArgoNavi made their first contribution in https://github.com/meshtastic/firmware/pull/6921\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.9.f223b8a...v2.6.10.9ce4455" - } - ], - "alpha": [ - { - "id": "v2.7.22.96dd647", - "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json", - "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647" - }, - { - "id": "v2.7.21.1370b23", - "title": "Meshtastic Firmware 2.7.21.1370b23 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.21.1370b23", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.21.1370b23/firmware-2.7.21.1370b23.json", - "release_notes": "> [!WARNING]\r\n> Due to resource constraints, the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward. \r\n> Support continues on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- Add T5-4.7-S3 Epaper Pro support. #6625\r\n- Apply Thailand NBTC 920-925 MHz limits (27 dBm, 10% duty cycle). #9827\r\n- Switch nRF52840 builds to C++17. #9874\r\n- Clean up SEN5X warnings. #9884\r\n- Refactor BaseUI emotes. #9896\r\n- Add spoof detection in `UdpMulticastHandler`. #9905\r\n- Enable LNA by default on Heltec v4.3. #9906\r\n- Rotate MUI for the Heltec V4 + TFT expansion kit. #9938\r\n- Make `hexDump()` take a `const` buffer. #9944\r\n- Add `meshtasticd` config metadata. #10001\r\n- Add `MESHTASTIC_EXCLUDE_ACCELEROMETER`. #10004\r\n- Adapt MUI WiFi map tile downloads for Heltec V4. #10011\r\n- Fix Mesh-tab WiFi map and exclude-screen behavior. #10038\r\n- Include Thinknode M5 minor fixes. #10049\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Remove GPS baudrate locking on the Seeed Xiao S3 kit. #9374\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownouts. #9754\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients. #9770\r\n- Fix traceroute over MQTT when the uplink node is encrypted. #9798\r\n- Extend Debian sourcedeb cache expiration. #9858\r\n- Fix T-LoRA Pager SPI bus sharing between SX1262 and the SD card. #9870\r\n- Update `ESP8266Audio` to the Meshtastic fork for compatibility. #9872\r\n- Fix `rak_wismeshtag` low-voltage reboot hangs after app configuration. #9897\r\n- Preserve `pki_encrypted` and `public_key` when relaying UDP multicast packets to radio. #9916\r\n- Add the new RAK 13302 power curve. #9929\r\n- Fix MQTT settings not persisting when the broker is unreachable. #9934\r\n- Fix BMP detection by not returning early during BME address scans. #9935\r\n- Enforce infrastructure-role minimums even when scaling is disabled. #9937\r\n- Fix traceroute hop rendering for `ffff` / unknown-dB hops. #9945\r\n- Fix NodeInfo suppression so it only applies to external requests. #9947\r\n- Enable touch-to-backlight on T-Echo, not just T-Echo Plus. #9953\r\n- Prevent licensed users from rebroadcasting packets to or from unlicensed users. #9958\r\n- Add the `heltec_mesh_node_t096` board. #9960\r\n- Add Cardputer-Adv I2S audio support. #9963\r\n- Fix the Cyrillic OLED double-space issue. #9971\r\n- Add `LED_BUILTIN` for `tlora_v1`. #9973\r\n- Add a timeout for PPA uploads. #9989\r\n- Exclude the web server, Paxcounter, and a few other components on original ESP32 boards to avoid IRAM overflow. #10005\r\n- Rework External Notifications logic. #10006\r\n- Improve STM32WL support. #10015\r\n- Configure NFC pins as GPIO for older bootloaders. #10016\r\n- Fix `TransmitHistory` epoch handling. #10017\r\n- Inherit `build_unflags` for `wio-sdk-wm1110`. #10034\r\n- Remove PSRAM from `tbeam` boards to reclaim IRAM. #10036\r\n- Move `t5s3_epaper_inkhud` to `extra`. #10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update `meshtastic-esp32_https_server` to digest `b78f12c`. #9851\r\n- Update `meshtastic/device-ui` through digests `622b034`, `f36d2a9`, `7b1485b`, and `1897dd1`. #9864 #9940 #10023 #10044 #10050\r\n- Update `GxEPD2` to `v1.6.8`. #9918\r\n- Update `pnpm/action-setup` to `v5`. #9926\r\n- Update `dorny/test-reporter` to `v3`. #9981\r\n- Clean up LewisHe library references and dependency matching, and tighten Renovate scheduling. #10007 #10008 #10039\r\n- Update `Adafruit_BME680` to `v2.0.6`. #10009\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23\r\n" - }, - { - "id": "v2.7.20.6658ec2", - "title": "Meshtastic Firmware 2.7.20.6658ec2 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.20.6658ec2", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.20.6658ec2/firmware-2.7.20.6658ec2.json", - "release_notes": "## 🚀 Enhancements\r\n\r\n- Xiao NRF - define suitable i2c pins for the sub-variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8866\r\n- Fix(MQTT): Send first MapReport as soon as possible by @ndoo in https://github.com/meshtastic/firmware/pull/8872\r\n- Feat/add sfa30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9372\r\n- Improved Periodic class by @harry-iii-lord in https://github.com/meshtastic/firmware/pull/9501\r\n- InkHUD: Allow non-system applets to subscribe to input events by @Vortetty in https://github.com/meshtastic/firmware/pull/9514\r\n- Cardputer Kit by @caveman99 in https://github.com/meshtastic/firmware/pull/9540\r\n- Skip header items when enabling the InkHUD menu cursor by @zeropt in https://github.com/meshtastic/firmware/pull/9552\r\n- ExternalNotification and StatusLED now call AmbientLighting to update… by @jp-bennett in https://github.com/meshtastic/firmware/pull/9554\r\n- BaseUI: Favorite Screen Signal Quality improvement by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9566\r\n- Add battery curve for T-Beam 1 watt by @jp-bennett in https://github.com/meshtastic/firmware/pull/9585\r\n- Add sdl libs for native builds by @jp-bennett in https://github.com/meshtastic/firmware/pull/9595\r\n- Log `rxBad` PacketHeaders with more info (`id`, `relay_node`) like `printPacket` by @compumike in https://github.com/meshtastic/firmware/pull/9614\r\n- Develop to master by @thebentern in https://github.com/meshtastic/firmware/pull/9618\r\n- Fix a lot of low level cppcheck warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9623\r\n- Convert `GPS*` global and some new in gps.cpp to `unique_ptr` by @Jorropo in https://github.com/meshtastic/firmware/pull/9628\r\n- Replace delete in RedirectablePrint.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9642\r\n- Replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9643\r\n- Replace delete in RadioInterface.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9645\r\n- Replace delete in CryptoEngine.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9649\r\n- Replace delete in AudioThread.h with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9651\r\n- Scaling tweaks by @NomDeTom in https://github.com/meshtastic/firmware/pull/9653\r\n- InkHUD: Favorite Map Applet by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9654\r\n- Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 by @caveman99 in https://github.com/meshtastic/firmware/pull/9663\r\n- #9623 resolved a local shadow of next_key by converting it to int. by @caveman99 in https://github.com/meshtastic/firmware/pull/9665\r\n- Zip a few gitrefs down by @caveman99 in https://github.com/meshtastic/firmware/pull/9672\r\n- Limit http connections and add free heap check before allocating for SSL by @thebentern in https://github.com/meshtastic/firmware/pull/9693\r\n- Split module includes for AQ module by @oscgonfer in https://github.com/meshtastic/firmware/pull/9711\r\n- Align telemetry broadcast want_response behavior with traceroute by @thebentern in https://github.com/meshtastic/firmware/pull/9717\r\n- InkHUD: Nodelist cleanup by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9737\r\n- Add GPIO_DETECT_PA portduino config, and support 13302 detection with it by @jp-bennett in https://github.com/meshtastic/firmware/pull/9741\r\n- Remove unused global rIf that shadows locals and fails cppcheck by @weebl2000 in https://github.com/meshtastic/firmware/pull/9743\r\n- Add Transmit history persistence for respecting traffic intervals between reboots by @thebentern in https://github.com/meshtastic/firmware/pull/9748\r\n- Unlock 0x8B5 register macro guard for SX162 by @thebentern in https://github.com/meshtastic/firmware/pull/9777\r\n- Enhancement(mesh): remove late packets from tx queue when full by @m1nl in https://github.com/meshtastic/firmware/pull/9779\r\n- Add json file rotation option by @jp-bennett in https://github.com/meshtastic/firmware/pull/9783\r\n- PPA: Remove Ubuntu 25.04, Add 26.04 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9789\r\n- Deb: Handle offline builds more gracefully by @vidplace7 in https://github.com/meshtastic/firmware/pull/9791\r\n- Remove \"x\" permission bits from some source files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9794\r\n- Add some lora parameter clamping logic to coalesce to defaults and enforce some bounds by @thebentern in https://github.com/meshtastic/firmware/pull/9808\r\n- Add back FEM LNA mode configuration for LoRa by @thebentern in https://github.com/meshtastic/firmware/pull/9809\r\n- More RAK6421 work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9813\r\n- Add ROUTER_LATE and TAK_TRACKER to congestion scaling exemption by @h3lix1 in https://github.com/meshtastic/firmware/pull/9818\r\n- Add ROUTER_LATE to telemetry impolite role check by @h3lix1 in https://github.com/meshtastic/firmware/pull/9819\r\n- Add ROUTER_LATE to infrastructure init and config preservation by @h3lix1 in https://github.com/meshtastic/firmware/pull/9820\r\n- Update Heltec Tracker v2 to version KCT8103L. by @Quency-D in https://github.com/meshtastic/firmware/pull/9822\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Fix heltec v4 tft dependency by @Quency-D in https://github.com/meshtastic/firmware/pull/9507\r\n- Apply SX1262 register 0x8B5 patch for improved GC1109 RX sensitivity by @weebl2000 in https://github.com/meshtastic/firmware/pull/9571\r\n- Hold GC1109 FEM power during deep sleep for LNA RX wake by @weebl2000 in https://github.com/meshtastic/firmware/pull/9572\r\n- Fix some random compiler warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9596\r\n- Add missing openocd_target to custom nrf52 boards by @Stary2001 in https://github.com/meshtastic/firmware/pull/9603\r\n- Fixes on SCD4X admin comands by @oscgonfer in https://github.com/meshtastic/firmware/pull/9607\r\n- Feat/add scd30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9609\r\n- Zero entire public key array instead of only first byte by @weebl2000 in https://github.com/meshtastic/firmware/pull/9619\r\n- Respect DontMqttMeBro flag regardless of channel PSK by @weebl2000 in https://github.com/meshtastic/firmware/pull/9626\r\n- Undefine LED_BUILTIN for Heltec v2 variant by @ericbarch in https://github.com/meshtastic/firmware/pull/9647\r\n- Fix typo in PIN_GPS_SWITCH by @Jorropo in https://github.com/meshtastic/firmware/pull/9648\r\n- Workaround NCP5623 and LP5562 I2C builds by @Jorropo in https://github.com/meshtastic/firmware/pull/9652\r\n- RadioLib edge-triggered interrupts robustness by @compumike in https://github.com/meshtastic/firmware/pull/9658\r\n- Add USB_MODE=1 for Station G2 - Solving all my serial issues. by @h3lix1 in https://github.com/meshtastic/firmware/pull/9660\r\n- Fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct by @caveman99 in https://github.com/meshtastic/firmware/pull/9664\r\n- Fix/rak3401 button by @LN4CY in https://github.com/meshtastic/firmware/pull/9668\r\n- Undefine LED_BUILTIN for 9m2ibr_aprs_lora_tracker by @mrekin in https://github.com/meshtastic/firmware/pull/9685\r\n- BLE Pairing fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9701\r\n- Implement 'agc' reset for SX126x & LR11x0 chip families by @weebl2000 in https://github.com/meshtastic/firmware/pull/9705\r\n- Add explicit dependency on mklittlefs. by @cpatulea in https://github.com/meshtastic/firmware/pull/9708\r\n- Platform: nrf52: Fix typo in BLEDfuSecure filename by @KokoSoft in https://github.com/meshtastic/firmware/pull/9709\r\n- Meshtasticd: Add Luckfox Lyra Hat pinmaps by @vidplace7 in https://github.com/meshtastic/firmware/pull/9730\r\n- Fix WisMesh Tap V2 env mess by @thebentern in https://github.com/meshtastic/firmware/pull/9734\r\n- Hopefully fix remaining cppcheck issues by @caveman99 in https://github.com/meshtastic/firmware/pull/9745\r\n- Add heltec-v4.3 board by @Quency-D in https://github.com/meshtastic/firmware/pull/9753\r\n- Fix Bluetooth on RAK Ethernet Gateway by removing MESHTASTIC_EXCLUDE_… by @thebentern in https://github.com/meshtastic/firmware/pull/9755\r\n- Increase PSRAM malloc threshold from 256 bytes to 2048 bytes by @thebentern in https://github.com/meshtastic/firmware/pull/9758\r\n- Don't launch canned message when waking screen or silencing notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/9762\r\n- Fix nRF52 AsyncUDP multicast TX/RX race on W5100S by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9765\r\n- Avoid memory leak when possibly malformed packet is received by @m1nl in https://github.com/meshtastic/firmware/pull/9781\r\n- Add ADS1115 ADC to recognition as used on RAK6421 Hat by @caveman99 in https://github.com/meshtastic/firmware/pull/9790\r\n- Improve resource cleanup on connection close (and make server API a unique pointer) by @thebentern in https://github.com/meshtastic/firmware/pull/9799\r\n- Spelling fixes by @ldoolitt in https://github.com/meshtastic/firmware/pull/9801\r\n- Spelling fixes in .md files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9810\r\n- Treat ROUTER_LATE like ROUTER for power management and defaults by @h3lix1 in https://github.com/meshtastic/firmware/pull/9815\r\n- Add ROUTER_LATE use the same rebroadcast rules as ROUTER by @h3lix1 in https://github.com/meshtastic/firmware/pull/9816\r\n- Prevent router-like roles from auto-favoriting DM peers by @h3lix1 in https://github.com/meshtastic/firmware/pull/9821\r\n- Fix(t1000e): reclassify P0.04 as sensor power enable GPIO by @weebl2000 in https://github.com/meshtastic/firmware/pull/9826\r\n- Don't double-blink Thinknode-M1 Power LED while charging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9829\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update adafruit mpu6050 to v2.2.9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9611\r\n- Update Sensirion Core to v0.7.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9613\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9616\r\n- Update actions/stale action to v10.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9669\r\n- Update meshtastic-GxEPD2 digest to c7eb4c3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9694\r\n- Update radiolib to v7.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9695\r\n- Update sensorlib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9727\r\n- Update meshtastic-st7789 digest to 9ee76d6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9729\r\n- Update adafruit mlx90614 to v2.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9756\r\n- Update adafruit_tsl2561 to v1.1.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9757\r\n- Update platformio/espressif32 to v6.13.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9759\r\n- Update platformio/nordicnrf52 to v10.11.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9760\r\n- Update adafruit dps310 to v1.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9763\r\n- Update platformio/ststm32 to v19.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9764\r\n- Update adafruit ahtx0 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9766\r\n- Update github artifact actions (major) by @app/renovate in https://github.com/meshtastic/firmware/pull/9767\r\n- Update crazy-max/ghaction-import-gpg action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9787\r\n- Update arduinojson to v6.21.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9788\r\n- Update dorny/test-reporter action to v2.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9796\r\n- Update docker/login-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9806\r\n- Update docker/setup-qemu-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9807\r\n- Update docker/setup-buildx-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9824\r\n- Update docker/build-push-action action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9832\r\n- Update docker/metadata-action action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9833\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9839\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.20.6658ec2" - }, - { - "id": "v2.7.19.bb3d6d5", - "title": "Meshtastic Firmware 2.7.19.bb3d6d5 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.19.bb3d6d5", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.19.bb3d6d5/firmware-2.7.19.bb3d6d5.json", - "release_notes": "## 🚀 Enhancements\r\n\r\n- Feat/add sen5x by @oscgonfer in https://github.com/meshtastic/firmware/pull/7245\r\n- Add initial Nix shell by @agustinmista in https://github.com/meshtastic/firmware/pull/8530\r\n- InkHUD Menu improvements by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8975\r\n- Cut NRF52 bluetooth power usage by @phaseloop in https://github.com/meshtastic/firmware/pull/8992\r\n- Feat(GPS): Support Softsleep with WAKE-UP pin on PA1010D by @ndoo in https://github.com/meshtastic/firmware/pull/9078\r\n- NRF52 - power management improvements by @phaseloop in https://github.com/meshtastic/firmware/pull/9211\r\n- Change canned message recipient's previous page to send page by @scobert969 in https://github.com/meshtastic/firmware/pull/9227\r\n- Make BLE TX power configurable for nRF52 variants by @teizz in https://github.com/meshtastic/firmware/pull/9232\r\n- Add interrupt for external charge detection by @jp-bennett in https://github.com/meshtastic/firmware/pull/9332\r\n- Add a watchdog module to meshsolar. by @Quency-D in https://github.com/meshtastic/firmware/pull/9337\r\n- Add StatusMessage module and config overrides by @jp-bennett in https://github.com/meshtastic/firmware/pull/9351\r\n- Implement graduated scaling for NodeInfo send timeout based on active mesh size by @thebentern in https://github.com/meshtastic/firmware/pull/9364\r\n- BaseUI: Bubbles for messages by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9365\r\n- Enable long interleaving mode for LR11x0 and SX128x by @Jorropo in https://github.com/meshtastic/firmware/pull/9399\r\n- Add portduino_status, assign hardware device IDs... by @jp-bennett in https://github.com/meshtastic/firmware/pull/9441\r\n- Add support for Hackaday Communicator function keys by @jp-bennett in https://github.com/meshtastic/firmware/pull/9444\r\n- Add on-screen keyboard to InkHUD by @scobert969 in https://github.com/meshtastic/firmware/pull/9445\r\n- Move more code out of main-nrf52 into variant.cpp by @jp-bennett in https://github.com/meshtastic/firmware/pull/9450\r\n- BaseUI Message Bubble Improvements by @Xaositek in https://github.com/meshtastic/firmware/pull/9452\r\n- Support fully direct request/responses by @esev in https://github.com/meshtastic/firmware/pull/9455\r\n- Add reply bot module with DM-only responses and rate limiting by @mattatat25 in https://github.com/meshtastic/firmware/pull/9456\r\n- Add model workflows by @thebentern in https://github.com/meshtastic/firmware/pull/9462\r\n- Add custom ringtone definition for RAK4631 and enable buzzer pin by @thebentern in https://github.com/meshtastic/firmware/pull/9481\r\n- Remove unused hmx variable by @EricSesterhennX41 in https://github.com/meshtastic/firmware/pull/9529\r\n- Make LED_POWER blip even in critical battery by @jp-bennett in https://github.com/meshtastic/firmware/pull/9545\r\n- Added toggable config and default for larger screens to enable / hide bubbles on chat messages by @thebentern in https://github.com/meshtastic/firmware/pull/9560\r\n- Add Slash Key to VirtualKeyboard by @Xaositek in https://github.com/meshtastic/firmware/pull/9563\r\n- Add support for CW2015 LiPo battery fuel gauge by @jp-bennett in https://github.com/meshtastic/firmware/pull/9564\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Just set LED_BUILTIN universally to -1, as we don't use it. by @jp-bennett in https://github.com/meshtastic/firmware/pull/8830\r\n- Device-install: Consistently use write-flash by @tyll in https://github.com/meshtastic/firmware/pull/8868\r\n- Added Minimesh variant by @uguraltinsoy in https://github.com/meshtastic/firmware/pull/9289\r\n- Fix uMesh RF POWER configuration error by @linser233 in https://github.com/meshtastic/firmware/pull/9326\r\n- Delete unused code by @EricSesterhennX41 in https://github.com/meshtastic/firmware/pull/9350\r\n- Feat(stm32): Add Milesight GS301 Bathroom Odor Detector by @ndoo in https://github.com/meshtastic/firmware/pull/9359\r\n- To fix the gps power rail issue on RAK 19007 when RAK12023+RAK12035 is installed by @Justin-Mann in https://github.com/meshtastic/firmware/pull/9409\r\n- Consolidate LoRa params / preset logic and fix display of preset values by @thebentern in https://github.com/meshtastic/firmware/pull/9413\r\n- Fix logic for rak12035 sensor default config and improve messaging by @Justin-Mann in https://github.com/meshtastic/firmware/pull/9414\r\n- Add pin sense to wake M6 on Solar Charge by @jp-bennett in https://github.com/meshtastic/firmware/pull/9416\r\n- Move Lora Init code into LoraInit.cpp/h by @jp-bennett in https://github.com/meshtastic/firmware/pull/9435\r\n- Replace strcpy with strncpy and null termination in `MQTT::publish` for JSON payloads by @k3an3 in https://github.com/meshtastic/firmware/pull/9436\r\n- Move device code from main.cpp to earlyInitVariant by @jp-bennett in https://github.com/meshtastic/firmware/pull/9438\r\n- Trackball revamp by @jp-bennett in https://github.com/meshtastic/firmware/pull/9440\r\n- Remove the unused OCV_ARRAYs and move one to a variant.h by @jp-bennett in https://github.com/meshtastic/firmware/pull/9442\r\n- Fix StoreForwardModule retry_delay multiplier always evaluating to 2x by @rcd in https://github.com/meshtastic/firmware/pull/9443\r\n- Add error handling for SPI command failures in LR11x0, RF95, and SX128x interfaces by @thebentern in https://github.com/meshtastic/firmware/pull/9447\r\n- Add Thinknode M4 variant_shutdown() by @jp-bennett in https://github.com/meshtastic/firmware/pull/9449\r\n- External Notification - handleReceived Rewrite by @Xaositek in https://github.com/meshtastic/firmware/pull/9454\r\n- Move input init to an init function in InputBroker by @jp-bennett in https://github.com/meshtastic/firmware/pull/9463\r\n- Initial serialModule cleanup by @jp-bennett in https://github.com/meshtastic/firmware/pull/9465\r\n- Avoid short-circuit evaluation issues in Telemetry by @oscgonfer in https://github.com/meshtastic/firmware/pull/9467\r\n- Add support for the hardware buttons on Retia.io's Bluetooth Nugget device by @treysis in https://github.com/meshtastic/firmware/pull/9468\r\n- Remove stale variant.h defines by @jp-bennett in https://github.com/meshtastic/firmware/pull/9470\r\n- More variant.h cleanup. LED_NOTIFICATION, remove dead code, etc by @jp-bennett in https://github.com/meshtastic/firmware/pull/9477\r\n- Refuse to send legacy DMs simply because the remote public key is unknown by @jp-bennett in https://github.com/meshtastic/firmware/pull/9485\r\n- Fix esp32 ota bin name in scripts by @thebentern in https://github.com/meshtastic/firmware/pull/9488\r\n- You get an RTC, and you get an RTC! (delete HAS_RTC as it wasn't actually doing much) by @jp-bennett in https://github.com/meshtastic/firmware/pull/9493\r\n- Don't ever define PIN_LED or BLE_LED_INVERTED by @jp-bennett in https://github.com/meshtastic/firmware/pull/9494\r\n- Missed in reviews - fixing send bubble by @Xaositek in https://github.com/meshtastic/firmware/pull/9505\r\n- Power off control pin on Thinknode m5 during deepsleep and add RTC by @jp-bennett in https://github.com/meshtastic/firmware/pull/9510\r\n- Prefer EXT_PWR_DETECT pin over chargingVolt to detect power unplugged by @jp-bennett in https://github.com/meshtastic/firmware/pull/9511\r\n- Rename LED_PIN to LED_POWER, move handling out of main to dedicated module by @jp-bennett in https://github.com/meshtastic/firmware/pull/9512\r\n- Inkhud battery icon improvements. by @Vortetty in https://github.com/meshtastic/firmware/pull/9513\r\n- Make sure we always return a value in NodeDB::restorePreferences() by @EricSesterhennX41 in https://github.com/meshtastic/firmware/pull/9516\r\n- Fix config.display.use_long_node_name not saving by @Xaositek in https://github.com/meshtastic/firmware/pull/9522\r\n- Implement UDP multicast handler start/stop to ensure proper lifecycle by @thebentern in https://github.com/meshtastic/firmware/pull/9524\r\n- Undefine LED_BUILTIN for `tlora-v2-1-1_6-tcxo` by @mrekin in https://github.com/meshtastic/firmware/pull/9531\r\n- HotFix for ReplyBot (PR #9456) by @Xaositek in https://github.com/meshtastic/firmware/pull/9532\r\n- Fix hop_limit upgrade detection by @rcd in https://github.com/meshtastic/firmware/pull/9550\r\n- Meshtasticd: Fix install on Fedora 43 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9556\r\n- RPM: Include meshtasticd-start.sh by @vidplace7 in https://github.com/meshtastic/firmware/pull/9561\r\n- Fix embedded null byte truncation in ATAK strings by @niccellular in https://github.com/meshtastic/firmware/pull/9570\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update XPowersLib to v0.3.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9354\r\n- Update meshtastic/device-ui digest to 613c095 by @app/renovate in https://github.com/meshtastic/firmware/pull/9383\r\n- Update meshtastic-esp32_https_server digest to b0f3960 by @app/renovate in https://github.com/meshtastic/firmware/pull/9393\r\n- Update lewisxhe-SensorLib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9395\r\n- Update SensorLib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9396\r\n- Update pschatzmann_arduino-audio-driver to v0.2.1 by @app/renovate in https://github.com/meshtastic/firmware/pull/9398\r\n- Update meshtastic/device-ui digest to 37ad715 by @app/renovate in https://github.com/meshtastic/firmware/pull/9403\r\n- Update LovyanGFX to v1.2.19 by @app/renovate in https://github.com/meshtastic/firmware/pull/9405\r\n- Update GxEPD2 to v1.6.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9412\r\n- Update meshtastic/device-ui digest to 69739b8 by @app/renovate in https://github.com/meshtastic/firmware/pull/9448\r\n- Update libch341-spi-userspace digest to af9bc27 by @app/renovate in https://github.com/meshtastic/firmware/pull/9472\r\n- Update meshtastic/device-ui digest to 63967a4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9475\r\n- Update Adafruit MPU6050 to v2.2.7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9525\r\n- Update NeoPixel to v1.15.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9530\r\n- Update meshtastic-esp8266-oled-ssd1306 digest to 21e484f by @app/renovate in https://github.com/meshtastic/firmware/pull/9533\r\n- Update Adafruit MPU6050 to v2.2.8 by @app/renovate in https://github.com/meshtastic/firmware/pull/9534\r\n- Update meshtastic/device-ui digest to 6c75195 by @app/renovate in https://github.com/meshtastic/firmware/pull/9553\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.18.fb3bf78...v2.7.19.bb3d6d5" - }, - { - "id": "v2.7.18.fb3bf78", - "title": "Meshtastic Firmware 2.7.18.fb3bf78 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.18.fb3bf78", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.18.fb3bf78/firmware-2.7.18.fb3bf78.json", - "release_notes": "## 🚀 Enhancements\r\n\r\n- Move PMSA003I to separate class and update AQ telemetry by @oscgonfer in https://github.com/meshtastic/firmware/pull/7190\r\n- Multi message storage by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8182\r\n- Add support for setting API port from the config file by @tedwardd in https://github.com/meshtastic/firmware/pull/8435\r\n- PIN_PWR_DELAY_MS --> PERIPHERAL_WARMUP_MS by @fifieldt in https://github.com/meshtastic/firmware/pull/8467\r\n- CLIENT_BASE: Act like ROUTER_LATE for fav'd nodes, instead of like ROUTER by @korbinianbauer in https://github.com/meshtastic/firmware/pull/8567\r\n- Rak3112 support by @ford-jones in https://github.com/meshtastic/firmware/pull/8591\r\n- Add systemd wrapper by @tedwardd in https://github.com/meshtastic/firmware/pull/8676\r\n- Adding support for InkHUD joystick navigation for the Seeed Wio Tracker L1 E-ink by @zeropt in https://github.com/meshtastic/firmware/pull/8678\r\n- Gr language specific font by @apo-mak in https://github.com/meshtastic/firmware/pull/8808\r\n- Detect if NTP is active on native by @jp-bennett in https://github.com/meshtastic/firmware/pull/8962\r\n- Add Rebooting to DFU mode notification as a simple pop-up by @Xaositek in https://github.com/meshtastic/firmware/pull/8970\r\n- PIO: Fix ESP32 sub-variant inheritance by @vidplace7 in https://github.com/meshtastic/firmware/pull/8983\r\n- Prep work for better Store and Forward by @jp-bennett in https://github.com/meshtastic/firmware/pull/8999\r\n- Additional Emoji by @Ixitxachitl in https://github.com/meshtastic/firmware/pull/9020\r\n- Add Russell, an STM32WL balloon-optimized node by @ndoo in https://github.com/meshtastic/firmware/pull/9079\r\n- Add menus for Smart Position, Broadcast Interval and Position Interval by @Xaositek in https://github.com/meshtastic/firmware/pull/9080\r\n- Improve sanitizeString function for Node Names by @Xaositek in https://github.com/meshtastic/firmware/pull/9086\r\n- Add Temporary Mute to Home frame and unbury Notification Options by @Xaositek in https://github.com/meshtastic/firmware/pull/9097\r\n- Add a welcome message for new contributors by @fifieldt in https://github.com/meshtastic/firmware/pull/9119\r\n- Calculate hops correctly even when hop_start==0 by @esev in https://github.com/meshtastic/firmware/pull/9120\r\n- Add STORE_FORWARD_PLUSPLUS_APP to core portnum checks by @jp-bennett in https://github.com/meshtastic/firmware/pull/9127\r\n- Faster rotary encoder events by @brad112358 in https://github.com/meshtastic/firmware/pull/9146\r\n- Add support for LilyGo T-Echo Plus by @thebentern in https://github.com/meshtastic/firmware/pull/9149\r\n- Refactored some of the system menus to the new DRY method (Redux) by @Xaositek in https://github.com/meshtastic/firmware/pull/9152\r\n- Add a .clang-format file by @Jorropo in https://github.com/meshtastic/firmware/pull/9154\r\n- Add custom coding rate configuration for LoRa by @jp-bennett in https://github.com/meshtastic/firmware/pull/9155\r\n- Add list of text message packet IDs, and check for dupes by @jp-bennett in https://github.com/meshtastic/firmware/pull/9180\r\n- Add option to Mute/Unmute Channel to BaseUI by @Xaositek in https://github.com/meshtastic/firmware/pull/9194\r\n- Mute: Nodes by @ford-jones in https://github.com/meshtastic/firmware/pull/9209\r\n- Screenless Devices want to mute too! by @Xaositek in https://github.com/meshtastic/firmware/pull/9210\r\n- Migrate all of the Meshtastic API attributes into the ini as a source of truth by @thebentern in https://github.com/meshtastic/firmware/pull/9214\r\n- Pioarduino preparation by @MartinEmrich in https://github.com/meshtastic/firmware/pull/9223\r\n- Meshtastic unified OTA by @thebentern in https://github.com/meshtastic/firmware/pull/9231\r\n- Fix TFT_MESH settings across setting and recalling by @Xaositek in https://github.com/meshtastic/firmware/pull/9234\r\n- Add release notes generation and publishing workflow by @thebentern in https://github.com/meshtastic/firmware/pull/9255\r\n- Unified ESP32 OTA firmware by @vidplace7 in https://github.com/meshtastic/firmware/pull/9258\r\n- Add support for uMesh Modules by @linser233 in https://github.com/meshtastic/firmware/pull/9259\r\n- Extra pins by @jp-bennett in https://github.com/meshtastic/firmware/pull/9260\r\n- BaseUI: Autosave Messages by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9269\r\n- Node Actions Menu Overhaul by @Xaositek in https://github.com/meshtastic/firmware/pull/9287\r\n- Added I2C scanner a check for the QMC6310N. by @lewisxhe in https://github.com/meshtastic/firmware/pull/9305\r\n- Allow ICM20948 IMU to sleep by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9324\r\n- Meshtastic OTA (moar) by @thebentern in https://github.com/meshtastic/firmware/pull/9327\r\n- Add sqlite depdendency (Cherry-picks from sfpp) by @fifieldt in https://github.com/meshtastic/firmware/pull/9328\r\n- Add interrupt for external charge detection by @jp-bennett in https://github.com/meshtastic/firmware/pull/9332\r\n- Improve BaseUI Preset Change Flow by @Xaositek in https://github.com/meshtastic/firmware/pull/9343\r\n- Implement graduated scaling for NodeInfo send timeout based on active mesh size by @thebentern in https://github.com/meshtastic/firmware/pull/9364\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- RTC: PCF85063 support, port to SensorLib 0.3.1 by @WillyJL in https://github.com/meshtastic/firmware/pull/8061\r\n- Added tcxo definition to mesh-tab by @valzzu in https://github.com/meshtastic/firmware/pull/8604\r\n- Preliminary Thinknode M4 Support by @caveman99 in https://github.com/meshtastic/firmware/pull/8754\r\n- Actions: Compact manifest job output summary by @vidplace7 in https://github.com/meshtastic/firmware/pull/8957\r\n- PlatformIO: Restructure networking_base for re-use by @vidplace7 in https://github.com/meshtastic/firmware/pull/8964\r\n- Add LilyGO T-Beam 1W support by @santosvivos in https://github.com/meshtastic/firmware/pull/8967\r\n- PIO: Remove useless inheritence (references extends env) by @vidplace7 in https://github.com/meshtastic/firmware/pull/8987\r\n- Macro guard heap_caps_malloc_extmem_enable from SENSECAP_INDICATOR by @Xaositek in https://github.com/meshtastic/firmware/pull/9007\r\n- Add Rak 6421 autoconf by @jp-bennett in https://github.com/meshtastic/firmware/pull/9010\r\n- Implement basic github action comment reporting target diffs by @Jorropo in https://github.com/meshtastic/firmware/pull/9022\r\n- In protobuf update, allow develop branch to auto-update by @jp-bennett in https://github.com/meshtastic/firmware/pull/9027\r\n- 🔧 Fix LNA/PA power control for Heltec v4, wireless tracker v2 by @weebl2000 in https://github.com/meshtastic/firmware/pull/9029\r\n- Shame do not complain about missing targets by @Jorropo in https://github.com/meshtastic/firmware/pull/9032\r\n- Action: skip trying to comment binary size change results if it is not a PR by @Jorropo in https://github.com/meshtastic/firmware/pull/9033\r\n- Fix gps pin defs for various NRF variants. by @NomDeTom in https://github.com/meshtastic/firmware/pull/9034\r\n- Fix Rotary enc long press by @brad112358 in https://github.com/meshtastic/firmware/pull/9039\r\n- Add needed support bits for the Meshstick by @jp-bennett in https://github.com/meshtastic/firmware/pull/9042\r\n- In statusLEDModule, also detect isCharging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9050\r\n- PlatformIO: Re-Org ESP32 family shared props by @vidplace7 in https://github.com/meshtastic/firmware/pull/9060\r\n- Cleanup: Remove icarus custom framework-arduinoespressif32 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9064\r\n- M6 shutdown and LEDs work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9065\r\n- Implement HAS_PHYSICAL_KEYBOARD for devices with physical keyboards by @Xaositek in https://github.com/meshtastic/firmware/pull/9071\r\n- KZ_863 is not wide lora by @fifieldt in https://github.com/meshtastic/firmware/pull/9075\r\n- In autoconf, don't probe Wire unless i2c device is set by @jp-bennett in https://github.com/meshtastic/firmware/pull/9081\r\n- Correctly set type for event_mode max() position threshold by @vidplace7 in https://github.com/meshtastic/firmware/pull/9083\r\n- Fix PR#8061 SensorLib nRF ThinkNode M-series by @vidplace7 in https://github.com/meshtastic/firmware/pull/9084\r\n- Pioarduino .gitignore by @vidplace7 in https://github.com/meshtastic/firmware/pull/9085\r\n- Pass GH_TOKEN to shame's gh run download step by @Jorropo in https://github.com/meshtastic/firmware/pull/9087\r\n- GPS Menu Validation Fix - Missed in Reviews by @Xaositek in https://github.com/meshtastic/firmware/pull/9093\r\n- Noop \"download\" portion of #shame by @vidplace7 in https://github.com/meshtastic/firmware/pull/9114\r\n- Syntax fix for first timer welcome bot. by @fifieldt in https://github.com/meshtastic/firmware/pull/9144\r\n- Fix link formatting in welcome message by @fifieldt in https://github.com/meshtastic/firmware/pull/9163\r\n- Fixed shouldFilterReceived function to check prev relay according to the function definition by @strngr in https://github.com/meshtastic/firmware/pull/9168\r\n- Add EByte EoRa-Hub by @vidplace7 in https://github.com/meshtastic/firmware/pull/9169\r\n- Fix zero in sp02 and Heart Rate on screen by @sergeygalkin in https://github.com/meshtastic/firmware/pull/9174\r\n- RadioInterface::getRetransmissionMsec now handles encrypted packets c… by @rbreesems in https://github.com/meshtastic/firmware/pull/9184\r\n- Added support for the new SSD1306 control panel. by @lewisxhe in https://github.com/meshtastic/firmware/pull/9192\r\n- Fix Function + M in code by @Xaositek in https://github.com/meshtastic/firmware/pull/9200\r\n- Fix TSL2591 detection by adding command bit to register read by @heathdutton in https://github.com/meshtastic/firmware/pull/9215\r\n- Fix screen not sleeping due to power status updates by @heathdutton in https://github.com/meshtastic/firmware/pull/9216\r\n- Fix rotary regression and tighten up playBeep by @thebentern in https://github.com/meshtastic/firmware/pull/9221\r\n- Use correct name for ALT_BUTTON_PIN by @jp-bennett in https://github.com/meshtastic/firmware/pull/9225\r\n- CH341 MAC address derivation from serial and product string by @jp-bennett in https://github.com/meshtastic/firmware/pull/9226\r\n- T-Watch S3 Plus GPS support by @mverch67 in https://github.com/meshtastic/firmware/pull/9235\r\n- Update diy_promicro platformio.ini by @mrekin in https://github.com/meshtastic/firmware/pull/9245\r\n- Recover `long_name`, `short_name` from our own NodeDB entry if device.proto is unreadable by @compumike in https://github.com/meshtastic/firmware/pull/9248\r\n- Remove a strlcpy reference by @jp-bennett in https://github.com/meshtastic/firmware/pull/9249\r\n- Add unified OTA to manifest by @thebentern in https://github.com/meshtastic/firmware/pull/9261\r\n- Tiny - include mt-ota in firmware zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/9275\r\n- EXCLUDE_AUDIO on (original) ESP32 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9276\r\n- Partition name in manifest script by @thebentern in https://github.com/meshtastic/firmware/pull/9294\r\n- SafeFile: use atomic rename-with-overwrite, rather than non-atomic delete-then-rename by @compumike in https://github.com/meshtastic/firmware/pull/9296\r\n- Fix OTA partition name matching by @thebentern in https://github.com/meshtastic/firmware/pull/9302\r\n- T-Deck Pro: speed up eink force refresh by @vicliu624 in https://github.com/meshtastic/firmware/pull/9303\r\n- Small fix in register size for SHT4X by @oscgonfer in https://github.com/meshtastic/firmware/pull/9309\r\n- Fix GPS for T-Watch S3 plus by @mverch67 in https://github.com/meshtastic/firmware/pull/9312\r\n- Adds Custom battery curve for thinknode m6 by @jp-bennett in https://github.com/meshtastic/firmware/pull/9313\r\n- /api/v1/fromradio OPTIONS handler: fix sending proper HTTP response. by @cpatulea in https://github.com/meshtastic/firmware/pull/9322\r\n- Heltec V4 TFT metadata by @thebentern in https://github.com/meshtastic/firmware/pull/9325\r\n- Filter BLE updates that don't change pairing status by @jp-bennett in https://github.com/meshtastic/firmware/pull/9333\r\n- Don't Mute DMs just because we mute a channel by @Xaositek in https://github.com/meshtastic/firmware/pull/9348\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Rp2xx0: Update to arduino-pico 5.4.4 by @vidplace7 in https://github.com/meshtastic/firmware/pull/8979\r\n- Replace PIO fuzzy version matches (reproducible builds) by @vidplace7 in https://github.com/meshtastic/firmware/pull/8984\r\n- PIO: Renovate all the things by @vidplace7 in https://github.com/meshtastic/firmware/pull/8994\r\n- Update lewisxhe/SensorLib to 0.3.3 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9061\r\n- Update meshtastic-esp8266-oled-ssd1306 digest to b34c681 by @app/renovate in https://github.com/meshtastic/firmware/pull/9062\r\n- Update meshtastic/device-ui digest to 272defc by @app/renovate in https://github.com/meshtastic/firmware/pull/9166\r\n- Update dorny/test-reporter action to v2.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9167\r\n- Update INA226 to v0.6.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9247\r\n- Update meshtastic/device-ui digest to 12f8cdd by @app/renovate in https://github.com/meshtastic/firmware/pull/9263\r\n- Update meshtastic-gxepd2 digest to a05c11c by @app/renovate in https://github.com/meshtastic/firmware/pull/9264\r\n- Update ArduinoJson to v6.21.5 by @app/renovate in https://github.com/meshtastic/firmware/pull/9265\r\n- Update GxEPD2 to v1.6.5 by @app/renovate in https://github.com/meshtastic/firmware/pull/9266\r\n- Update ESP8266SAM to v1.1.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9271\r\n- Update pschatzmann_arduino-audio-driver to v0.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9272\r\n- Renovate: Ignore lovyangfx for elecrow-panel by @vidplace7 in https://github.com/meshtastic/firmware/pull/9279\r\n- Update RadioLib to v7.5.0 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9281\r\n- Update meshtastic/device-ui digest to 5a870c6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9301\r\n- Update Adafruit BMP280 to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9307\r\n- Update meshtastic/device-ui digest to 3480b73 by @app/renovate in https://github.com/meshtastic/firmware/pull/9353\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.17.9058cce...v2.7.18.fb3bf78" - }, - { - "id": "v2.7.17.9058cce", - "title": "Meshtastic Firmware 2.7.17.9058cce Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.17.9058cce", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.17.9058cce/firmware-2.7.17.9058cce.json", - "release_notes": "> [!WARNING]\r\n> If you experience immediate boot-loops after updating, this likely indicates that you need perform a full erase and flash. If you installed the previously revoked 2.7.17 release with the NimBLE 2 upgrade and paired the device with bluetooth, this will occur.\r\n\r\n## 🚀 What's Changed\r\n* Support overriding GPS serial pins on all architectures by @Stary2001 in https://github.com/meshtastic/firmware/pull/8486\r\n* Swap GPS pins for GPS TX/RX only for T114/T-Echo by @Xaositek in https://github.com/meshtastic/firmware/pull/8751\r\n* Remove native from the build, and remove the required permissions by @NomDeTom in https://github.com/meshtastic/firmware/pull/8685\r\n* Swap the GPS serial port pins. by @Quency-D in https://github.com/meshtastic/firmware/pull/8756\r\n* More GPS pin flips for devices by @Xaositek in https://github.com/meshtastic/firmware/pull/8760\r\n* Remove screen activation in powerExit function by @jp-bennett in https://github.com/meshtastic/firmware/pull/8779\r\n* Add LOG_POWERFSM and LOG_INPUT debug macros by @jp-bennett in https://github.com/meshtastic/firmware/pull/8791\r\n* Fix ifdef statement after ST7796 merge to resolve screen color issues by @Xaositek in https://github.com/meshtastic/firmware/pull/8796\r\n* Update dorny/test-reporter action to v2.3.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8809\r\n* InkHUD: Replace assert in UTF8 decoder to prevent unexpected reboot by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8807\r\n* Update meshtastic/device-ui digest to 3bf3322 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8814\r\n* tryfix screen.cpp ifdefs by @jp-bennett in https://github.com/meshtastic/firmware/pull/8816\r\n* Add WiFi Toggle to System frame to re-enable by @Xaositek in https://github.com/meshtastic/firmware/pull/8802\r\n* #if defined conditions for WiFi by @jp-bennett in https://github.com/meshtastic/firmware/pull/8815\r\n* Add initial support for Hackaday Communicator by @jp-bennett in https://github.com/meshtastic/firmware/pull/8771\r\n* M5Stack UnitC6L - Enabled MQTT and WEBSERVER by default by @RikerZhu in https://github.com/meshtastic/firmware/pull/8679\r\n* Initial Chatter 2.0 fix for baseUI by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8615\r\n* Make GPS_TX_PIN the serial TX and GPS_RX_PIN the serial RX for all NRF variants by @Stary2001 in https://github.com/meshtastic/firmware/pull/8772\r\n* Update XPowersLib to v0.3.2 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8823\r\n* RPM: Fix broken builds (bad backmerge) by @vidplace7 in https://github.com/meshtastic/firmware/pull/8787\r\n* Flags and scripts for size reduction on NRF52 -> Currently targeting … by @thebentern in https://github.com/meshtastic/firmware/pull/8825\r\n* Plain RAK4631 should not compile EInk and TFT display code by @thebentern in https://github.com/meshtastic/firmware/pull/8811\r\n* Fix for high power drain after shutdown on Heltec T114. by @rbomze in https://github.com/meshtastic/firmware/pull/8800\r\n* Bump release version by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8786\r\n* Move everything from /arch to /variant by @jp-bennett in https://github.com/meshtastic/firmware/pull/8831\r\n* Move device specific OCV curves to their respective device.h by @jp-bennett in https://github.com/meshtastic/firmware/pull/8834\r\n* Add 'cleanup' to required PR labels by @jp-bennett in https://github.com/meshtastic/firmware/pull/8835\r\n* Don't scale up the frequency of telemetry sending by @RCGV1 in https://github.com/meshtastic/firmware/pull/8664\r\n* Update actions/stale action to v10.1.1 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8848\r\n* Update alpine Docker tag to v3.23 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8853\r\n* Promicro documentation update by @NomDeTom in https://github.com/meshtastic/firmware/pull/8864\r\n* Optimization flags for all NRF52 targets to reduce code size by @Donix212 in https://github.com/meshtastic/firmware/pull/8854\r\n* Update meshtastic/device-ui digest to 4fb5f24 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8862\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8871\r\n* promicro doesn't need limiting by @NomDeTom in https://github.com/meshtastic/firmware/pull/8873\r\n* Enable USB_MODE to acknowledge RTS/DTR reset signal from esptool for heltec-v4 by @h3lix1 in https://github.com/meshtastic/firmware/pull/8881\r\n* Fix #8883 (lora-Pager fter playing the notification, voltage does not disappear from the speaker) by @polarikus in https://github.com/meshtastic/firmware/pull/8884\r\n* Resolve #8887 (T-LoRaPager Vibration on New Message Delivery) by @polarikus in https://github.com/meshtastic/firmware/pull/8888\r\n* OnScreenKeyboard Improvement with Joystick and UpDown Encoder by @whywilson in https://github.com/meshtastic/firmware/pull/8379\r\n* [T-LoraPager]Disable vibration if needed by @polarikus in https://github.com/meshtastic/firmware/pull/8895\r\n* Tryfix: T3S3 ePaper eInk updates by @mverch67 in https://github.com/meshtastic/firmware/pull/8898\r\n* Improved R1 Neo & muzi-base buzzer beeps for GPS on/off by @simon-muzi in https://github.com/meshtastic/firmware/pull/8870\r\n* Meshtastic build manifest by @vidplace7 in https://github.com/meshtastic/firmware/pull/8248\r\n* Fix backwards buttons on Thinknode-M1 by @jp-bennett in https://github.com/meshtastic/firmware/pull/8901\r\n* Update protobuf name of FRIED_CHICKEN by @jp-bennett in https://github.com/meshtastic/firmware/pull/8903\r\n* Guard 2M PHY mode for NimBLE by @thebentern in https://github.com/meshtastic/firmware/pull/8890\r\n* Renovate: fix BH1750_WE by @vidplace7 in https://github.com/meshtastic/firmware/pull/8767\r\n* Fixed the issue where T-Echo did not completely shut down peripherals… by @lewisxhe in https://github.com/meshtastic/firmware/pull/8524\r\n* Fix apply device-install permissions by @vidplace7 in https://github.com/meshtastic/firmware/pull/8911\r\n* Update platformio/espressif32 to v6.12.0 by @vidplace7 in https://github.com/meshtastic/firmware/pull/7697\r\n* Fix #8915 [Bug]: Exception Decoder does not recognize the backtrace by @polarikus in https://github.com/meshtastic/firmware/pull/8917\r\n* Actions: Fix release manifest formating by @vidplace7 in https://github.com/meshtastic/firmware/pull/8918\r\n* ARCtastic runners by @vidplace7 in https://github.com/meshtastic/firmware/pull/8904\r\n* Update peter-evans/create-pull-request action to v8 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8919\r\n* PIO: Fix closedcube lib reference by @vidplace7 in https://github.com/meshtastic/firmware/pull/8920\r\n* Use PSRAM to reduce heap usage percentage on ESP32 with PSRAM by @samm-git in https://github.com/meshtastic/firmware/pull/8891\r\n* Create new screen colors for BaseUI by @Xaositek in https://github.com/meshtastic/firmware/pull/8921\r\n* Enable Muzi-base LED notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/8925\r\n* Update System Frame for improved rendering on devices by @Xaositek in https://github.com/meshtastic/firmware/pull/8923\r\n* Add I2C device check for seesaw device on native by @jp-bennett in https://github.com/meshtastic/firmware/pull/8927\r\n* Use truncated position for smart position by @RCGV1 in https://github.com/meshtastic/firmware/pull/8906\r\n* Properly turn off power pins at shutdown for m3 by @jp-bennett in https://github.com/meshtastic/firmware/pull/8935\r\n* Update meshtastic/device-ui digest to 2746a1c by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8936\r\n* Use 'gh-action-runner' action for \"Check\" jobs. by @vidplace7 in https://github.com/meshtastic/firmware/pull/8938\r\n* Optimize builds to reduce duplicate dependency checks by @vidplace7 in https://github.com/meshtastic/firmware/pull/8943\r\n* Update actions/cache action to v5 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8944\r\n* Fix #8899 [Bug]: [TloraPager] RotaryEncoder crash by @polarikus in https://github.com/meshtastic/firmware/pull/8933\r\n* Mark implicit ACK for MQTT as MQTT transport by @GUVWAF in https://github.com/meshtastic/firmware/pull/8939\r\n* Fix GPS Buffer full issue on NRF52480 (Seeed T1000E) by @fifieldt in https://github.com/meshtastic/firmware/pull/8956\r\n* Actions: Compact manifest job output summary by @vidplace7 in https://github.com/meshtastic/firmware/pull/8957\r\n* Add JSON packet recording option to native by @jp-bennett in https://github.com/meshtastic/firmware/pull/8930\r\n* Implement Long Turbo preset by @thebentern in https://github.com/meshtastic/firmware/pull/8985\r\n* Be more judicious about responding to want_response in existing meshes by @thebentern in https://github.com/meshtastic/firmware/pull/9014\r\n* First position send validation by @thebentern in https://github.com/meshtastic/firmware/pull/9023\r\n* Calculate hops correctly even when hop_start==0 by @esev in https://github.com/meshtastic/firmware/pull/9120\r\n* Revert NimBLE 2.X upgrade by @thebentern in https://github.com/meshtastic/firmware/pull/9125\r\n\r\n## New Contributors\r\n* @RikerZhu made their first contribution in https://github.com/meshtastic/firmware/pull/8679\r\n* @rbomze made their first contribution in https://github.com/meshtastic/firmware/pull/8800\r\n* @Donix212 made their first contribution in https://github.com/meshtastic/firmware/pull/8854\r\n* @polarikus made their first contribution in https://github.com/meshtastic/firmware/pull/8884\r\n* @phaseloop made their first contribution in https://github.com/meshtastic/firmware/pull/8858\r\n* @samm-git made their first contribution in https://github.com/meshtastic/firmware/pull/8891\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.16.a597230...v2.7.17.9058cce" - }, - { - "id": "v2.7.16.a597230", - "title": "Meshtastic Firmware 2.7.16.a597230 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.16.a597230", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.16.a597230/firmware-esp32-2.7.16.a597230.zip", - "release_notes": "## 🚀 What's Changed\r\n* Bugfix: Don't toggle BLE when choosing active state by @jp-bennett in https://github.com/meshtastic/firmware/pull/8579\r\n* chore(deps): update meshtastic/device-ui digest to 28167c6 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8583\r\n* Only call stopNow if we're nagging by @thebentern in https://github.com/meshtastic/firmware/pull/8601\r\n* Clean up GPS toggle logging by @jp-bennett in https://github.com/meshtastic/firmware/pull/8629\r\n* Reset the calibration data back to 0 when doing a compass calibration by @jp-bennett in https://github.com/meshtastic/firmware/pull/8648\r\n* chore(deps): update dorny/test-reporter action to v2.2.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8637\r\n* Fix RPM builds by @vidplace7 in https://github.com/meshtastic/firmware/pull/8659\r\n* Linux: Fix silly EPEL9 mistake by @vidplace7 in https://github.com/meshtastic/firmware/pull/8660\r\n* Fix ble rssi crash by @thebentern in https://github.com/meshtastic/firmware/pull/8661\r\n* Unify uptime formatting by @jasonbcox in https://github.com/meshtastic/firmware/pull/8677\r\n* chore(deps): update meshtastic-esp8266-oled-ssd1306 digest to 2887bf4 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8688\r\n* Actually respect wake_on_motion setting by @jp-bennett in https://github.com/meshtastic/firmware/pull/8690\r\n* Add a reset pulse signal to the OLED. by @Quency-D in https://github.com/meshtastic/firmware/pull/8691\r\n* Missed one by @thebentern in https://github.com/meshtastic/firmware/pull/8694\r\n* nrf52 watchdog (attempt #2) by @SebKuzminsky in https://github.com/meshtastic/firmware/pull/8670\r\n* Fix build when MESHTASTIC_EXCLUDE_PKI is defined by @jasonbcox in https://github.com/meshtastic/firmware/pull/8698\r\n* Fix MenuHandler when MESHTASTIC_EXCLUDE_PKI is defined by @jasonbcox in https://github.com/meshtastic/firmware/pull/8701\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8707\r\n* Add Thinknode M6 by @caveman99 in https://github.com/meshtastic/firmware/pull/8705\r\n* Update Kongduino-Adafruit_nRFCrypto digest to 8cde718 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8708\r\n* Update actions/checkout action to v6 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8695\r\n* Add WisMesh Tag OCV array by @Avi0n in https://github.com/meshtastic/firmware/pull/8646\r\n* R1 Neo - Added OCV_ARRAY from measured discharge curve testing + update ADC multiplier by @simon-muzi in https://github.com/meshtastic/firmware/pull/8716\r\n* Log error if startReceive fails in LR11x0Interface by @jp-bennett in https://github.com/meshtastic/firmware/pull/8718\r\n* Tweak OCV_ARRAY 100% voltage to take into account charger hysteresis and voltage sag after charge by @simon-muzi in https://github.com/meshtastic/firmware/pull/8720\r\n* Thinknode M3 support against master by @jp-bennett in https://github.com/meshtastic/firmware/pull/8630\r\n* M6 leds by @jp-bennett in https://github.com/meshtastic/firmware/pull/8742\r\n* Further fix compass calibration by @jp-bennett in https://github.com/meshtastic/firmware/pull/8740\r\n* More quickly hide \"Shutting Down\" to prevent it showing on Eink sleep screen by @Xaositek in https://github.com/meshtastic/firmware/pull/8749\r\n* Prevent double-registering of Rotary Encoder on TLora Pager by @thebentern in https://github.com/meshtastic/firmware/pull/8746\r\n* 3401 fix by @caveman99 in https://github.com/meshtastic/firmware/pull/8755\r\n* Add support for muzi-base by @jp-bennett in https://github.com/meshtastic/firmware/pull/8753\r\n* Add requestFocus() in CannedMessages by @jp-bennett in https://github.com/meshtastic/firmware/pull/8770\r\n* Update Sensirion Core to v0.7.2 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8551\r\n* Update INA226 to v0.6.5 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8645\r\n* Update NonBlockingRTTTL to v1.4.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8541\r\n* Update platformio/ststm32 to v19.4.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8433\r\n* adding support for the ST7796 + creating a new variant of the T-beam by @Nasimovy in https://github.com/meshtastic/firmware/pull/6575\r\n* Correct vertical alignment for Muzi_Base on On Screen Keyboard by @Xaositek in https://github.com/meshtastic/firmware/pull/8774\r\n\r\n## New Contributors\r\n* @Avi0n made their first contribution in https://github.com/meshtastic/firmware/pull/8646\r\n* @simon-muzi made their first contribution in https://github.com/meshtastic/firmware/pull/8716\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.15.567b8ea...v2.7.16.a597230" - }, - { - "id": "v2.7.14.e959000", - "title": "Meshtastic Firmware 2.7.14.e959000 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.14.e959000", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.14.e959000/firmware-esp32-2.7.14.e959000.zip", - "release_notes": "## 🚀 What's Changed\r\n* Add the identification code for the DA217 triaxial accelerometer. by @Quency-D in https://github.com/meshtastic/firmware/pull/8526\r\n* fix strlcpy compile error in Ubuntu 22.04 by @mverch67 in https://github.com/meshtastic/firmware/pull/8520\r\n* Packaging: Add libbsd where needed by @vidplace7 in https://github.com/meshtastic/firmware/pull/8533\r\n* Add support for RAK_WISMESH_TAP_V2 and RAK3401 hardware models by @DanielCao0 in https://github.com/meshtastic/firmware/pull/8537\r\n* Fix: missing T-Deck Pro key '0' by @mverch67 in https://github.com/meshtastic/firmware/pull/8564\r\n* Store hop/mqtt/transport mechanism info in S&F by @weebl2000 in https://github.com/meshtastic/firmware/pull/8560\r\n* Reject legacy text message DMs by @jp-bennett in https://github.com/meshtastic/firmware/pull/8562\r\n* AddFromContact: Don't auto-favorite when `CLIENT_BASE`; don't update `last_heard` unless `CLIENT_BASE` by @compumike in https://github.com/meshtastic/firmware/pull/8495\r\n* Persist favourites on NodeDB reset by @ford-jones in https://github.com/meshtastic/firmware/pull/8292\r\n* Don't ack messages when mqtt client proxy is on but only uplink by @RCGV1 in https://github.com/meshtastic/firmware/pull/8578\r\n* Add API types, state, and log message in Debug screen. Added persistent \"Connected\" icon by @jp-bennett in https://github.com/meshtastic/firmware/pull/8576\r\n* Drop PKI acks if there is no downlink on MQTTClientProxy by @RCGV1 in https://github.com/meshtastic/firmware/pull/8580\r\n* Add the Heltec v4 expansion box. by @Quency-D in https://github.com/meshtastic/firmware/pull/8539\r\n* Update platform-native for WIFi lib fix by @jp-bennett in https://github.com/meshtastic/firmware/pull/8544\r\n* Reject legacy text message DMs by @jp-bennett in https://github.com/meshtastic/firmware/pull/8562\r\n* Bugfix: Don't toggle BLE when choosing active state by @jp-bennett in https://github.com/meshtastic/firmware/pull/8579\r\n* Try-fix traceroute panic by @thebentern in https://github.com/meshtastic/firmware/pull/8568\r\n* chore(deps): update meshtastic/device-ui digest to 28167c6 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8583\r\n* Only call stopNow if we're nagging by @thebentern in https://github.com/meshtastic/firmware/pull/8601\r\n\r\n## New Contributors\r\n* @l0g-lab made their first contribution in https://github.com/meshtastic/firmware/pull/7926\r\n* @dirkmueller made their first contribution in https://github.com/meshtastic/firmware/pull/8320\r\n* @steven52880 made their first contribution in https://github.com/meshtastic/firmware/pull/8330\r\n* @miketweaver made their first contribution in https://github.com/meshtastic/firmware/pull/8355\r\n* @Paplewski made their first contribution in https://github.com/meshtastic/firmware/pull/8362\r\n* @igorka48 made their first contribution in https://github.com/meshtastic/firmware/pull/8187\r\n* @korbinianbauer made their first contribution in https://github.com/meshtastic/firmware/pull/8432\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.12.45f15b8...v2.7.14.e959000" - }, - { - "id": "v2.7.13.597fa0b", - "title": "Meshtastic Firmware 2.7.13.597fa0b Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.13.597fa0b", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.13.597fa0b/firmware-esp32-2.7.13.597fa0b.zip", - "release_notes": "> [!IMPORTANT]\r\n> This release disables device telemetry broadcasts over the mesh by default. If you want to opt back in, you will need to re-enable this in the apps.\r\n\r\n> [!WARNING]\r\n> If you experience immediate bluetooth pairing failures or failure to boot after updating, this likely indicates that you need to do a full erase and flash. Consider backing up your settings before updating.\r\n\r\n## 🚀 What's Changed\r\n* Update python Docker tag to v3.14 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8255\r\n* fix: Move `#include \"variant.h\"` to top of file (fixes #8276) by @ndoo in https://github.com/meshtastic/firmware/pull/8278\r\n* Update meshtastic/device-ui digest to 6d8cc22 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8275\r\n* NimBLE speedup by @thebentern in https://github.com/meshtastic/firmware/pull/8281\r\n* Fix Station G2 Lora Power Settings by @fifieldt in https://github.com/meshtastic/firmware/pull/8273\r\n* chore(deps): update github/codeql-action action to v4 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8250\r\n* Fix BLE stateful issues by @thebentern in https://github.com/meshtastic/firmware/pull/8287\r\n* Attach an interrupt to EXT_PWR_DETECT if present, and force a screen … by @jp-bennett in https://github.com/meshtastic/firmware/pull/8284\r\n* Update XPowersLib to v0.3.1 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8303\r\n* Bump release version by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8304\r\n* Double the number of bluetooth bonds NimBLE will store (from 3 to 6) by @thebentern in https://github.com/meshtastic/firmware/pull/8296\r\n* mDNS: Advertise pio_env (for OTA scripts) by @vidplace7 in https://github.com/meshtastic/firmware/pull/8298\r\n* Master to develop by @jp-bennett in https://github.com/meshtastic/firmware/pull/8306\r\n* Actions: CI docker with a fancy matrix by @vidplace7 in https://github.com/meshtastic/firmware/pull/8253\r\n* GPS_POWER_TOGGLE no longer has a function, so purge by @jp-bennett in https://github.com/meshtastic/firmware/pull/8312\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8305\r\n* Remove T1000E GPS startup delay sequence by @fifieldt in https://github.com/meshtastic/firmware/pull/8236\r\n* Increase bluetooth 5.0 PHY speed and MTU on esp32_s3 by @h3lix1 in https://github.com/meshtastic/firmware/pull/8261\r\n* More BaseUI Frame Visibility Toggles by @Xaositek in https://github.com/meshtastic/firmware/pull/8252\r\n* Device Telemetry opt in by @thebentern in https://github.com/meshtastic/firmware/pull/8059\r\n* Fix muted protobuf compile errors by @thebentern in https://github.com/meshtastic/firmware/pull/8316\r\n* Master backmerge by @thebentern in https://github.com/meshtastic/firmware/pull/8317\r\n* chore(deps): update meshtastic/device-ui digest to 3fb7c0e by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8291\r\n* Nodelist: choice of long or short name by @l0g-lab in https://github.com/meshtastic/firmware/pull/7926\r\n* Ble reconnect prefetch bug fix, plus some speed enhancements by @h3lix1 in https://github.com/meshtastic/firmware/pull/8324\r\n* Avoid exceeding allocated buffers when doing MQTT proxying by @dirkmueller in https://github.com/meshtastic/firmware/pull/8320\r\n* Fix erroneous limiting of power in Ham Mode by @fifieldt in https://github.com/meshtastic/firmware/pull/8322\r\n* Fix bug: can not detect battery status while using INA226 by @steven52880 in https://github.com/meshtastic/firmware/pull/8330\r\n* rework sensor instantiation to saves memory by removing the static allocation by @Links2004 in https://github.com/meshtastic/firmware/pull/8054\r\n* Fix multitude of warnings during builds on MeshTiny by @Xaositek in https://github.com/meshtastic/firmware/pull/8331\r\n* Fix multitude of warnings during builds on MeshTiny by @Xaositek in https://github.com/meshtastic/firmware/pull/8331\r\n* Revert \"Fix Station G2 Lora Power Settings\" by @thebentern in https://github.com/meshtastic/firmware/pull/8332\r\n* Develop to master merge by @thebentern in https://github.com/meshtastic/firmware/pull/8337\r\n* Update stale_bot.yml by @NomDeTom in https://github.com/meshtastic/firmware/pull/8333\r\n* Update meshtastic/device-ui digest to 19b7855 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8346\r\n* Add a general-purpose packet cache by @erayd in https://github.com/meshtastic/firmware/pull/8341\r\n* Guarding PhoneAPI node-info staging with mutex to prevent BLE future foot-gun by @h3lix1 in https://github.com/meshtastic/firmware/pull/8354\r\n* Fix portduino native builds by @miketweaver in https://github.com/meshtastic/firmware/pull/8355\r\n* Log the lora frequency error when receiving a packet. by @jp-bennett in https://github.com/meshtastic/firmware/pull/8343\r\n* Bind python version to 3.13 by @Paplewski in https://github.com/meshtastic/firmware/pull/8362\r\n* Update actions/setup-node action to v6 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8339\r\n* Upgrade trunk by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8340\r\n* Ignore MQTT Client Proxy messages while not in sendpackets state by @thebentern in https://github.com/meshtastic/firmware/pull/8358\r\n* Force CannedMessages to another node to be a PKI DM by @jp-bennett in https://github.com/meshtastic/firmware/pull/8373\r\n* Update meshtastic/web to v2.6.7 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8381\r\n* Update DFRobot_RTU to v1.0.6 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8387\r\n* Update mcr.microsoft.com/devcontainers/cpp Docker tag to v2 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8375\r\n* Board support: RAK3401+RAK13302 1-watt by @DanielCao0 in https://github.com/meshtastic/firmware/pull/8140\r\n* Fixed battery voltage to show missing decimals by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8386\r\n* Gating off BaseUI code for Screenless nodes and InkHUD by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8384\r\n* Added support for SugarCube device by @igorka48 in https://github.com/meshtastic/firmware/pull/8187\r\n* Fix NimbleBluetooth reliability and performance by @compumike in https://github.com/meshtastic/firmware/pull/8385\r\n* Add a banner on startup when DEBUG_MUTE is enabled by @Stary2001 in https://github.com/meshtastic/firmware/pull/8402\r\n* Remove \"Phone GPS\" in order to correct GPS reporting by @Xaositek in https://github.com/meshtastic/firmware/pull/8407\r\n* Fix NimbleBluetooth: process fromPhoneQueue (phone->radio) before toPhoneQueue (radio->phone) by @compumike in https://github.com/meshtastic/firmware/pull/8404\r\n* Make packet pool dynamic again on STM32 as a workaround by @Stary2001 in https://github.com/meshtastic/firmware/pull/8400\r\n* InkHUD Map improvements by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8397\r\n* Include RSSI in rangetest csv by @ford-jones in https://github.com/meshtastic/firmware/pull/8395\r\n* Move airtime calculation to when Tx is complete by @GUVWAF in https://github.com/meshtastic/firmware/pull/8427\r\n* Upgrade trunk by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8369\r\n* Allow vibra or buzzer only notifications to obey cutoff by @Xaositek in https://github.com/meshtastic/firmware/pull/8342\r\n* Don't use unsigned integer type for negative SNR value by @korbinianbauer in https://github.com/meshtastic/firmware/pull/8432\r\n* InkHUD crash fix when nodes get deleted from NodeDB by @HarukiToreda in https://github.com/meshtastic/firmware/pull/8428\r\n* Address longName wrapping by @Xaositek in https://github.com/meshtastic/firmware/pull/8441\r\n* Update node to v24 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8476\r\n* Turn the e-ink backlight on for any brightness value over 0 by @jp-bennett in https://github.com/meshtastic/firmware/pull/8481\r\n* Add missed debug log line in RF95 Interface by @jp-bennett in https://github.com/meshtastic/firmware/pull/8490\r\n* Thinknode M5 ADC_MULTIPLIER to actually hit 100% charge by @jp-bennett in https://github.com/meshtastic/firmware/pull/8489\r\n* Better implementation of ExternalNotificationModule::stopNow by @Xaositek in https://github.com/meshtastic/firmware/pull/8492\r\n* Skip setting up Lora GPIO lines when using a ch341 radio on native by @jp-bennett in https://github.com/meshtastic/firmware/pull/8506\r\n* Fix boot on RP2040 by excluding new FreeRTOS task by @GUVWAF in https://github.com/meshtastic/firmware/pull/8508\r\n* Fix dismiss of ext. notification by @thebentern in https://github.com/meshtastic/firmware/pull/8512\r\n* Update device-install.sh to support heltec-v4 by @Melonbwead in https://github.com/meshtastic/firmware/pull/8509\r\n\r\n## New Contributors\r\n* @l0g-lab made their first contribution in https://github.com/meshtastic/firmware/pull/7926\r\n* @dirkmueller made their first contribution in https://github.com/meshtastic/firmware/pull/8320\r\n* @steven52880 made their first contribution in https://github.com/meshtastic/firmware/pull/8330\r\n* @miketweaver made their first contribution in https://github.com/meshtastic/firmware/pull/8355\r\n* @Paplewski made their first contribution in https://github.com/meshtastic/firmware/pull/8362\r\n* @igorka48 made their first contribution in https://github.com/meshtastic/firmware/pull/8187\r\n* @korbinianbauer made their first contribution in https://github.com/meshtastic/firmware/pull/8432\r\n* @Ixitxachitl made their first contribution in https://github.com/meshtastic/firmware/pull/8493\r\n* @mariusfaber98 made their first contribution in https://github.com/meshtastic/firmware/pull/8349\r\n* @shortwavesurfer2009 made their first contribution in https://github.com/meshtastic/firmware/pull/8137\r\n* @pa0lin082 made their first contribution in https://github.com/meshtastic/firmware/pull/8376\r\n* @Xavierhorwood made their first contribution in https://github.com/meshtastic/firmware/pull/6866\r\n* @Melonbwead made their first contribution in https://github.com/meshtastic/firmware/pull/8509\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.12.45f15b8...v2.7.13.597fa0b" - }, - { - "id": "v2.7.11.ee68575", - "title": "Meshtastic Firmware 2.7.11.ee68575 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.11.ee68575", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.11.ee68575/firmware-esp32-2.7.11.ee68575.zip", - "release_notes": "> [!IMPORTANT]\r\nDue to feedback received from users, UDP has been disabled by default and the default PSK decryption bridging logic has been removed for now until channel level controls can be added to a future release.\r\n\r\n> [!WARNING]\r\nRepeater role has been deprecated in this release and going forward.\r\n\r\n## 🚀 What's Changed\r\n* On screen keyboard by @thebentern in https://github.com/meshtastic/firmware/pull/7705\r\n* Update meshtastic/device-ui digest to 0f32b64 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7723\r\n* Update caveman99-stm32-Crypto digest to 1aa30eb by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7725\r\n* Renovate: Always use `master` as the base. by @vidplace7 in https://github.com/meshtastic/firmware/pull/7726\r\n* Add more text message test cases for meshpacket serializer by @TN666 in https://github.com/meshtastic/firmware/pull/7709\r\n* Reduce power of EU433 to 10dBm by @fifieldt in https://github.com/meshtastic/firmware/pull/7733\r\n* Backmerge to develop by @thebentern in https://github.com/meshtastic/firmware/pull/7744\r\n* Log more information about ignored packet by @notmasteryet in https://github.com/meshtastic/firmware/pull/7718\r\n* Setup ESP32 PM-specific capability flags by @m1nl in https://github.com/meshtastic/firmware/pull/7747\r\n* Add more test case for encrypted packet test by @TN666 in https://github.com/meshtastic/firmware/pull/7745\r\n* Backmerge by @thebentern in https://github.com/meshtastic/firmware/pull/7773\r\n* Bump release version by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/7777\r\n* BaseUI Show/Hide Frame Functionality by @Xaositek in https://github.com/meshtastic/firmware/pull/7382\r\n* We don't gotTime if time is 2019. by @fifieldt in https://github.com/meshtastic/firmware/pull/7772\r\n* Add On-Screen Keyboard for UpDown Encoder and Rotary Encoder. by @whywilson in https://github.com/meshtastic/firmware/pull/7762\r\n* Fix InputEvent variable usage with out initialization (random key events while using rotery encoder) by @Links2004 in https://github.com/meshtastic/firmware/pull/8015\r\n* Allow Left / Right Events for selection and improve encoder responsives by @Links2004 in https://github.com/meshtastic/firmware/pull/8016\r\n* Fix build fail on develop branch by @WillyJL in https://github.com/meshtastic/firmware/pull/8043\r\n* Fix more build failures by @WillyJL in https://github.com/meshtastic/firmware/pull/8044\r\n* Fix build with HAS_TELEMETRY 0 by @Links2004 in https://github.com/meshtastic/firmware/pull/8051\r\n* Move HTTP contentTypes to Flash - saves 768 Bytes of RAM by @Links2004 in https://github.com/meshtastic/firmware/pull/8055\r\n* Fix: use `lora.use_preset` config to get name by @GUVWAF in https://github.com/meshtastic/firmware/pull/8057\r\n* Resolve many warnings for BaseUI during builds by @Xaositek in https://github.com/meshtastic/firmware/pull/8063\r\n* Fix Rotary Encoder Button by @Links2004 in https://github.com/meshtastic/firmware/pull/8001\r\n* Add another seeed_xiao_nrf52840_kit build environment for I2C pinout by @NomDeTom in https://github.com/meshtastic/firmware/pull/8036\r\n* Add heltec_v4 board. by @Quency-D in https://github.com/meshtastic/firmware/pull/7845\r\n* Fix build errors by @Xaositek in https://github.com/meshtastic/firmware/pull/8067\r\n* Introduce Radio Preset elections through BaseUI by @Xaositek in https://github.com/meshtastic/firmware/pull/8071\r\n* Allow label enforcement job to run on self-hosted runners by @fifieldt in https://github.com/meshtastic/firmware/pull/7909\r\n* Bump release version by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8100\r\n* Upgrade trunk by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/8094\r\n* Add three expansion screens for heltec mesh solar. by @Quency-D in https://github.com/meshtastic/firmware/pull/7995\r\n* Update Adafruit BusIO to v1.17.4 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8098\r\n* Fix 2.4GHz reconfiguration on LR11xx by @Stary2001 in https://github.com/meshtastic/firmware/pull/8102\r\n* Feat/0-cost hops for favorite routers by @h3lix1 in https://github.com/meshtastic/firmware/pull/7992\r\n* If a packet is heard multiple times, rebroadcast using the highest hop limit by @erayd in https://github.com/meshtastic/firmware/pull/5534\r\n* Make sure next-hop is only set when they received us directly by @GUVWAF in https://github.com/meshtastic/firmware/pull/8053\r\n* Reduce cpu load by optimizing OSThread runOnce calls by @Links2004 in https://github.com/meshtastic/firmware/pull/8101\r\n* Correct inverted mute icon by @Xaositek in https://github.com/meshtastic/firmware/pull/8111\r\n* BaseUI - Saving GPS Format changes are required by @Xaositek in https://github.com/meshtastic/firmware/pull/8122\r\n* Properly output the TCXO Voltage in yaml by @jp-bennett in https://github.com/meshtastic/firmware/pull/8128\r\n* I2S: Fix silent RTTTL regression by @WillyJL in https://github.com/meshtastic/firmware/pull/8129\r\n* Revert cross-preset default-key bridging with UDP and disable UDP by default by @thebentern in https://github.com/meshtastic/firmware/pull/8130\r\n* Develop --> Master by @fifieldt in https://github.com/meshtastic/firmware/pull/8110\r\n* Range-test: Clean on reboot by @ford-jones in https://github.com/meshtastic/firmware/pull/7703\r\n* UIRenderer: display \"No GPS present\" only on the first line to avoid duplication by @plashchynski in https://github.com/meshtastic/firmware/pull/8136\r\n* Remove memcpy by @dfsx1 in https://github.com/meshtastic/firmware/pull/8079\r\n* Correct altitudeLine getting clobbered in the great merge by @Xaositek in https://github.com/meshtastic/firmware/pull/8138\r\n* Bug / Send upgraded (duplicate) packets to phone if the queue removal failed. by @h3lix1 in https://github.com/meshtastic/firmware/pull/8148\r\n* Validate CR and SF lora config by @thebentern in https://github.com/meshtastic/firmware/pull/8146\r\n* Finish deprecating the Repeater role behavior by @thebentern in https://github.com/meshtastic/firmware/pull/8144\r\n* Fix Heltec V3 missed button presses by @thebentern in https://github.com/meshtastic/firmware/pull/8167\r\n\r\n## New Contributors\r\n* @h3lix1 made their first contribution in https://github.com/meshtastic/firmware/pull/7992\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.10.94d4bdf...v2.7.11.ee68575" - }, - { - "id": "v2.7.10.94d4bdf", - "title": "Meshtastic Firmware 2.7.10.94d4bdf Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.10.94d4bdf", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.10.94d4bdf/firmware-esp32-2.7.10.94d4bdf.zip", - "release_notes": "## 🚀 What's Changed\r\n\r\n- BaseUI Show/Hide Frame Functionality by @Xaositek in https://github.com/meshtastic/firmware/pull/7382\r\n- Feature: Seamless Cross-Preset Communication via UDP Multicast Bridging by @ViezeVingertjes in https://github.com/meshtastic/firmware/pull/7753\r\n- Add CLIENT_BASE role: ROUTER for favorites, CLIENT otherwise (for attic/roof nodes!) by @compumike in https://github.com/meshtastic/firmware/pull/7873\r\n- Added Last Coordinate counter to Position screen by @HarukiToreda in https://github.com/meshtastic/firmware/pull/7865\r\n- Phone GPS display on Position Screen for BaseUI by @HarukiToreda in https://github.com/meshtastic/firmware/pull/7875\r\n- Add formatting and menu picking for other GPS format options by @Xaositek in https://github.com/meshtastic/firmware/pull/7974\r\n- Add RAK WisMesh Tap V2 (ESP32S3) Hardware Variant by @DanielCao0 in https://github.com/meshtastic/firmware/pull/7741\r\n- Add support for the Challenger rp2040 lora by @samuel-duffield1 in https://github.com/meshtastic/firmware/pull/7826\r\n- Add support for the RV-3028 on native Linux by @jp-bennett in https://github.com/meshtastic/firmware/pull/7802\r\n- T-Lora Pager: Support LR1121 and SX1280 models by @WillyJL in https://github.com/meshtastic/firmware/pull/7956\r\n- Add another seeed_xiao_nrf52840_kit build environment for I2C pinout by @NomDeTom in https://github.com/meshtastic/firmware/pull/8036\r\n- Add heltec_v4 board. by @Quency-D in https://github.com/meshtastic/firmware/pull/7845\r\n- C6l fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/8047\r\n- Add TSL2561 sensor by @davide125 in https://github.com/meshtastic/firmware/pull/7675\r\n- Add a new GPS model CM121. by @Quency-D in https://github.com/meshtastic/firmware/pull/7852\r\n- Make ExternalNotification show up in excluded_modules, more STM32 modules by @Stary2001 in https://github.com/meshtastic/firmware/pull/7797\r\n- Enable bmx160 on native by @jp-bennett in https://github.com/meshtastic/firmware/pull/7844\r\n- Fix memory leak in NRF52Bluetooth: allocate BluetoothStatus on stack, not heap by @compumike in https://github.com/meshtastic/firmware/pull/7965\r\n- Fix memory leak in NimbleBluetooth: allocate BluetoothStatus on stack, not heap by @compumike in https://github.com/meshtastic/firmware/pull/7964\r\n- Fix GPS gm_mktime memory leak by @compumike in https://github.com/meshtastic/firmware/pull/7981\r\n- Fix INA3221 higher current wrong readings by @macvenez in https://github.com/meshtastic/firmware/pull/7607\r\n- Fix InputEvent variable usage with out initialization (random key events while using rotery encoder) by @Links2004 in https://github.com/meshtastic/firmware/pull/8015\r\n- Fix Rotary Encoder Button by @Links2004 in https://github.com/meshtastic/firmware/pull/8001\r\n- Fix date display to be upper right bound by @Xaositek in https://github.com/meshtastic/firmware/pull/7876\r\n- Fix excluded modules configuration handling by @capricornusx in https://github.com/meshtastic/firmware/pull/7838\r\n- Fix build error in rak_wismesh_tap_v2 by @fifieldt in https://github.com/meshtastic/firmware/pull/7905\r\n- Fix build fail on develop branch by @WillyJL in https://github.com/meshtastic/firmware/pull/8043\r\n- Fix more build failures by @WillyJL in https://github.com/meshtastic/firmware/pull/8044\r\n- Fix last build issues on develop by @WillyJL in https://github.com/meshtastic/firmware/pull/8046\r\n- Fix build errors by @Xaositek in https://github.com/meshtastic/firmware/pull/8067\r\n- fix build with HAS_TELEMETRY 0 by @Links2004 in https://github.com/meshtastic/firmware/pull/8051\r\n- Fix device-install.bat baud rate by @fifieldt in https://github.com/meshtastic/firmware/pull/7816\r\n- Fix: use lora.use_preset config to get name by @GUVWAF in https://github.com/meshtastic/firmware/pull/8057\r\n- Show GPS Date properly in drawCommonHeader by @Xaositek in https://github.com/meshtastic/firmware/pull/7887\r\n- Make sure to ACK ACKs/replies if next-hop routing is used by @GUVWAF in https://github.com/meshtastic/firmware/pull/8052\r\n- Only stop retransmissions when receiving implicit ACK over LoRa by @GUVWAF in https://github.com/meshtastic/firmware/pull/7872\r\n- Allow Left / Right Events for selection and improve encoder responsives by @Links2004 in https://github.com/meshtastic/firmware/pull/8016\r\n- If usePreset is False, show value as Custom. by @Xaositek in https://github.com/meshtastic/firmware/pull/7812\r\n- (resubmission) Manual GitHub actions to allow building one target or arch by @NomDeTom in https://github.com/meshtastic/firmware/pull/7997\r\n- When DEBUG_HEAP is defined, add free heap bytes to every log line in RedirectablePrint::log_to_serial by @compumike in https://github.com/meshtastic/firmware/pull/8004\r\n- Setup ESP32 PM-specific capability flags by @m1nl in https://github.com/meshtastic/firmware/pull/7747\r\n- move HTTP contentTypes to Flash - saves 768 Bytes of RAM by @Links2004 in https://github.com/meshtastic/firmware/pull/8055\r\n- Portduino config refactor by @jp-bennett in https://github.com/meshtastic/firmware/pull/7796\r\n- Add BUILD_EPOCH to latest setup step. by @fifieldt in https://github.com/meshtastic/firmware/pull/7894\r\n- updated shebang to use a more standard path for bash in flashing scripts. by @vtrenton in https://github.com/meshtastic/firmware/pull/7922\r\n- Update RadioLib to v7.3.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/8065\r\n- Update Protobuf usage, add MLS, fix clock by @Xaositek in https://github.com/meshtastic/firmware/pull/8041\r\n- Portduino bump to fix gpiod bug by @jp-bennett in https://github.com/meshtastic/firmware/pull/8083\r\n- Ext notification fix (handle ringtone operations even when module is not enabled) by @thebentern in https://github.com/meshtastic/firmware/pull/8089\r\n- tlora-pager wake on button, and kb backlight toggling by @jp-bennett in https://github.com/meshtastic/firmware/pull/8090\r\n- Try-fix: Unstick that PhoneAPI state by @thebentern in https://github.com/meshtastic/firmware/pull/8091\r\n- Also pull a deviceID from esp32c6 devices by @jp-bennett in https://github.com/meshtastic/firmware/pull/8092\r\n- Clear last toradio on BLE disconnect by @thebentern in https://github.com/meshtastic/firmware/pull/8095\r\n\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.9.70724be...v2.7.10.94d4bdf" - }, - { - "id": "v2.7.9.70724be", - "title": "Meshtastic Firmware 2.7.9.70724be Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.9.70724be", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.9.70724be/firmware-esp32-2.7.9.70724be.zip", - "release_notes": "## 🚀 What's Changed\r\n* Add support for new ESP32 DIY variant 9m2ibr_aprs_lora_tracker by @ndoo in https://github.com/meshtastic/firmware/pull/7828\r\n* T-Lora Pager: Fix keyboard and improve rotary wheel haptic by @mverch67 in https://github.com/meshtastic/firmware/pull/7869\r\n* Fix esptool detection and baud rate issues in Windows batch scripts by @jeremiah-k in https://github.com/meshtastic/firmware/pull/7856\r\n* Upon receiving ACK/reply directly, only update next-hop if we’re the *sole* relayer by @GUVWAF in https://github.com/meshtastic/firmware/pull/7859\r\n* Fix merge conflict with test changes by @fifieldt in https://github.com/meshtastic/firmware/pull/7902\r\n* Fix: RotaryEncoder uninitialized kbchar by @mverch67 in https://github.com/meshtastic/firmware/pull/7889\r\n* Chore(deps): update meshtastic/device-ui digest to 233d18e by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7890\r\n* Reorganize 8MB partition for MUI devices by @mverch67 in https://github.com/meshtastic/firmware/pull/7860\r\n* Chore(deps): update meshtastic/device-ui digest to 3677476 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7925\r\n* Disable ATAK Plugin module for non-TAK roles by @thebentern in https://github.com/meshtastic/firmware/pull/7928\r\n* Use char buffer for probeResponse by @thebentern in https://github.com/meshtastic/firmware/pull/7870\r\n* Make phone queues use a static pointer queue by @thebentern in https://github.com/meshtastic/firmware/pull/7919\r\n* Add LOG_HEAP log type, and more heap debug messages by @jp-bennett in https://github.com/meshtastic/firmware/pull/7937\r\n* Unify build epoch to add flag in platformio-custom.py by @thebentern in https://github.com/meshtastic/firmware/pull/7917\r\n* Put guards in place around debug heap operations by @thebentern in https://github.com/meshtastic/firmware/pull/7955\r\n* Static memory pool allocation by @thebentern in https://github.com/meshtastic/firmware/pull/7966\r\n* Update meshtastic-esp8266-oled-ssd1306 digest to 0cbc26b by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7977\r\n* Fix json report crashes on esp32 by @thebentern in https://github.com/meshtastic/firmware/pull/7978\r\n* Scale probe buffer size based on current baud rate by @thebentern in https://github.com/meshtastic/firmware/pull/7975\r\n* Fix overflow of time value by @thebentern in https://github.com/meshtastic/firmware/pull/7984\r\n\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.8.a0c0388...v2.7.9.70724be" - }, - { - "id": "v2.7.7.5ae4ff9", - "title": "Meshtastic Firmware 2.7.7.5ae4ff9 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.7.5ae4ff9", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.7.5ae4ff9/firmware-esp32-2.7.7.5ae4ff9.zip", - "release_notes": "## What's Changed\r\n* Only send Neighbours if we have some to send. by @fifieldt in https://github.com/meshtastic/firmware/pull/7493\r\n* Fix freetext hang by @thebentern in https://github.com/meshtastic/firmware/pull/7781\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/7784\r\n* We don't gotTime if time is 2019. by @fifieldt in https://github.com/meshtastic/firmware/pull/7772\r\n* Can't trust RTCs to tell the time. by @fifieldt in https://github.com/meshtastic/firmware/pull/7779\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.6.834c3c5...v2.7.7.5ae4ff9" - }, - { - "id": "v2.7.6.834c3c5", - "title": "Meshtastic Firmware 2.7.6.834c3c5 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.6.834c3c5", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.6.834c3c5/firmware-esp32-2.7.6.834c3c5.zip", - "release_notes": "## 🚀 Enhancements\r\n* Add onboard message for devices with screens by @jp-bennett in https://github.com/meshtastic/firmware/pull/7655\r\n* Only gate PKC behind the simradio CLI flag by @jp-bennett in https://github.com/meshtastic/firmware/pull/7681\r\n* Add SDL option to BaseUI on Native by @jp-bennett in https://github.com/meshtastic/firmware/pull/7568\r\n* Initial stab at rak6421 autoconf by @jp-bennett in https://github.com/meshtastic/firmware/pull/7691\r\n* Update meshtastic/device-ui digest to 3dc7cf3 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7698\r\n* Support for T-Echo Lite, credits to @Szetya for doing all the heavy lifting! by @caveman99 in https://github.com/meshtastic/firmware/pull/7636\r\n* Add more text message test cases for meshpacket serializer by @TN666 in https://github.com/meshtastic/firmware/pull/7709\r\n* Initial attempt to get rfswitch working on Portduino by @jp-bennett in https://github.com/meshtastic/firmware/pull/7663\r\n* Don't update the NodeDB if the nodeinfo has a mismatching public key by @jp-bennett in https://github.com/meshtastic/firmware/pull/7652\r\n* Add BaseUI support for L1 EInk by @thebentern in https://github.com/meshtastic/firmware/pull/7751\r\n* Mesh solar integration by @thebentern in https://github.com/meshtastic/firmware/pull/7764\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix 'buildroot' compiles (OpenWRT) by @vidplace7 in https://github.com/meshtastic/firmware/pull/7620\r\n* Fix: apply 180 degree hw rotation for Indicator BaseUI by @mverch67 in https://github.com/meshtastic/firmware/pull/7660\r\n* Update platform-native digest to cd32f4e by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7662\r\n* Move heartbeat response before !available guard. by @jake-b in https://github.com/meshtastic/firmware/pull/7672\r\n* Docker: fix web assets location by @vidplace7 in https://github.com/meshtastic/firmware/pull/7683\r\n* Update meshtastic-esp8266-oled-ssd1306 diges\r\n* Docker: Update Debian images to trixie by @vidplace7 in https://github.com/meshtastic/firmware/pull/7621\r\n* Fix Tracerouter warnings by @thebentern in https://github.com/meshtastic/firmware/pull/7637\r\n* Don't include OLED fonts for international character sets by default by @thebentern in https://github.com/meshtastic/firmware/pull/7639\r\n* Fix marking LoRa transport mechanism by @GUVWAF in https://github.com/meshtastic/firmware/pull/7634\r\n* Thinknode button and backlight fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/7641\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/7647\r\n* Remove JSON serialization from most NRF52 targets by @thebentern in https://github.com/meshtastic/firmware/pull/7640\r\n* Wait for lead up before enable longlong action by @jp-bennett in https://github.com/meshtastic/firmware/pull/7648\r\nt to 9573abb by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7686\r\n* Update meshtastic/device-ui digest to 8f5094b by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7633\r\n* Update caveman99-stm32-Crypto digest to 1aa30eb by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7725\r\n* Renovate: Always use `master` as the base. by @vidplace7 in https://github.com/meshtastic/firmware/pull/7726\r\n* PKC fix by @jp-bennett in https://github.com/meshtastic/firmware/pull/7722\r\n* T-Lora Pager by @mverch67 in https://github.com/meshtastic/firmware/pull/7613\r\n* Fix: enable device telemetry for elecrow advanced series (MUI) by @mverch67 in https://github.com/meshtastic/firmware/pull/7757\r\n* Don't use pin 0 on RAK for input by @jp-bennett in https://github.com/meshtastic/firmware/pull/7755\r\n* Update meshtastic/device-ui digest to a3e0e1b by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7766\r\n\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.5.ddd1499...v2.7.6.834c3c5" - }, - { - "id": "v2.7.5.ddd1499", - "title": "Meshtastic Firmware 2.7.5.ddd1499 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.5.ddd1499", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.5.ddd1499/firmware-esp32-2.7.5.ddd1499.zip", - "release_notes": "\r\n## 🚀 What's Changed\r\n* Reorder for correct recognition by @caveman99 in https://github.com/meshtastic/firmware/pull/7604\r\n* Stop the bleeding with malicious NodeDB overwrites by @jp-bennett in https://github.com/meshtastic/firmware/pull/7596\r\n* Chore(deps): update actions/checkout action to v5 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7605\r\n* Mark meshPackets based on which interface received. by @jp-bennett in https://github.com/meshtastic/firmware/pull/7589\r\n* chore(deps): update actions/download-artifact action to v5 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7559\r\n* Adding medium and large RU fonts. Fixing RU string width calculation by @mrekin in https://github.com/meshtastic/firmware/pull/7498\r\n* nRF52840 promicro deepsleep fix with some additions by @MagnusKos in https://github.com/meshtastic/firmware/pull/7407\r\n* More spoof remediation by @jp-bennett in https://github.com/meshtastic/firmware/pull/7612\r\n\r\n## New Contributors\r\n* @MagnusKos made their first contribution in https://github.com/meshtastic/firmware/pull/7407\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.4.c1f4f79...v2.7.5.ddd1499" - }, - { - "id": "v2.7.4.c1f4f79", - "title": "Meshtastic Firmware 2.7.4.c1f4f79 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.4.c1f4f79", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.4.c1f4f79/firmware-esp32-2.7.4.c1f4f79.zip", - "release_notes": "## 🚀 Enhancements\r\n* Unify the shutdown proceedure by @jp-bennett in https://github.com/meshtastic/firmware/pull/7393\r\n* T-Deck Pro support by @mverch67 in https://github.com/meshtastic/firmware/pull/6936\r\n* Text message rate limiting should return routing error instead by @thebentern in https://github.com/meshtastic/firmware/pull/7365\r\n* WashTastic variant by @valzzu in https://github.com/meshtastic/firmware/pull/7450\r\n* Set canned_message.enabled to true when setting defaults by @jp-bennett in https://github.com/meshtastic/firmware/pull/7414\r\n* Add Trace Route on BaseUI by @whywilson in https://github.com/meshtastic/firmware/pull/7386\r\n* Add BRT3 timezone option to TZPicker menu by @barbabarros in https://github.com/meshtastic/firmware/pull/7438\r\n* Set firmware edition (for events) from userprefs by @thebentern in https://github.com/meshtastic/firmware/pull/7488\r\n* Heartbeat response by @thebentern in https://github.com/meshtastic/firmware/pull/7506\r\n* Airoha GPS - ignore estimated fixes by @fifieldt in https://github.com/meshtastic/firmware/pull/7429\r\n* [7353] Add all telemetry fields to json output by @rradillen in https://github.com/meshtastic/firmware/pull/7363\r\n* Event mode - limit smart position updates to at most every 5m by @powersjcb in https://github.com/meshtastic/firmware/pull/7505\r\n* Move BLE toggle menu option and add confirmation for canned messages in L1 by @thebentern in https://github.com/meshtastic/firmware/pull/7516\r\n* Initial support for the ThinkNode M5 by @jp-bennett in https://github.com/meshtastic/firmware/pull/7502\r\n\r\n## 🐛 Bug fixes and maintenace\r\n* ESP32: Initial sort variants by platform by @vidplace7 in https://github.com/meshtastic/firmware/pull/7340\r\n* ESP32c3: Migrate variants to new structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7342\r\n* Misc cppcheck fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/7370\r\n* RP2040/RP2350: Migrate variants to new structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7345\r\n* STM32: Migrate variants to new structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7389\r\n* UDP for RAK4631 Eth Gw and the t-eth-elite. Solves #7149 by @caveman99 in https://github.com/meshtastic/firmware/pull/7385\r\n* Restore High Resolution Hour Hand by @Xaositek in https://github.com/meshtastic/firmware/pull/7392\r\n* Fix UDP builds on nRF by @caveman99 in https://github.com/meshtastic/firmware/pull/7394\r\n* ESP32s3: Migrate variants to new structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7343\r\n* ARCH_STM32*WL* by @vidplace7 in https://github.com/meshtastic/firmware/pull/7397\r\n* Actions: pull_request_target is fun by @vidplace7 in https://github.com/meshtastic/firmware/pull/7398\r\n* Renovate: Use github-tags for XPowersLib updates by @vidplace7 in https://github.com/meshtastic/firmware/pull/7411\r\n* nRF52840: Migrate variants to new structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7396\r\n* Migrate remaining variants to new dir structure by @vidplace7 in https://github.com/meshtastic/firmware/pull/7412\r\n* Moves the shutdown thread into the Power class, make shutdown and reboot private by @jp-bennett in https://github.com/meshtastic/firmware/pull/7415\r\n* Upgrade trunk by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/7420\r\n* Add a verbose mode flag to meshtasticd by @jp-bennett in https://github.com/meshtastic/firmware/pull/7416\r\n* Update protobufs and classes by @github-actions[bot] in https://github.com/meshtastic/firmware/pull/7422\r\n* AG3335 GPS: Use NAVIC in India/Nepal, L1+L5 elsewhere. by @fifieldt in https://github.com/meshtastic/firmware/pull/7413\r\n* Use platformio-core to build the matrix by @vidplace7 in https://github.com/meshtastic/firmware/pull/7424\r\n* Deprecate disable_triple_click config by @jp-bennett in https://github.com/meshtastic/firmware/pull/7425\r\n* Update meshtastic/device-ui digest to c75d545 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7435\r\n* Add Nepal 865 MHz to 868 MHz by @WOD-MN in https://github.com/meshtastic/firmware/pull/7380\r\n* Add BR_902, Brazil 902MHz-907.5MHz by @fifieldt in https://github.com/meshtastic/firmware/pull/7399\r\n* Add NP_865 and BR_902 to region picker by @barbabarros in https://github.com/meshtastic/firmware/pull/7434\r\n* Actions: Combine embedded builds // split by variant subdir by @vidplace7 in https://github.com/meshtastic/firmware/pull/7417\r\n* Take control of our PRs! by @vidplace7 in https://github.com/meshtastic/firmware/pull/7445\r\n* Fix timezone definition for UTC in TZPicker function by @barbabarros in https://github.com/meshtastic/firmware/pull/7442\r\n* Fix MHz label by @Xaositek in https://github.com/meshtastic/firmware/pull/7455\r\n* Build RP2350 (Pi Pico 2) by @vidplace7 in https://github.com/meshtastic/firmware/pull/7441\r\n* Actions: Enforce PR labels by @vidplace7 in https://github.com/meshtastic/firmware/pull/7379\r\n* Rename Platformio.ini to platformio.ini | WashTastic by @valzzu in https://github.com/meshtastic/firmware/pull/7468\r\n* Fix MQTT config bugs by @thebentern in https://github.com/meshtastic/firmware/pull/7446\r\n* Clear position on GPS deactivation, unless using fixed position by @fifieldt in https://github.com/meshtastic/firmware/pull/7464\r\n* Validate Serial config console override modes by @thebentern in https://github.com/meshtastic/firmware/pull/7470\r\n* Bugfix Add rssi and snr to the store and forward code. by @mikecarper in https://github.com/meshtastic/firmware/pull/7462\r\n* Santa may be checking his list twice, but we only need this in the platformio.ini by @caveman99 in https://github.com/meshtastic/firmware/pull/7490\r\n* NodeDB count on MyNodeInfo for client progress reporting by @thebentern in https://github.com/meshtastic/firmware/pull/7489\r\n* Core portnums rebroadcast mode whitelist instead of blacklist by @thebentern in https://github.com/meshtastic/firmware/pull/7487\r\n* DEBUG_MUTE correctness by @Stary2001 in https://github.com/meshtastic/firmware/pull/7492\r\n* Workaround Webserver needing to stay up while Wifi is being turned off by @fifieldt in https://github.com/meshtastic/firmware/pull/7484\r\n* Update platformio/ststm32 to v19.3.0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7512\r\n* Bugfix Syntax error: \"(\" unexpected in device-update.sh by @mikecarper in https://github.com/meshtastic/firmware/pull/7514\r\n* Remember destination fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/7427\r\n* Rv3028 rtc fix by @tg-mw in https://github.com/meshtastic/firmware/pull/7524\r\n* Only toggle screen wake, don't break banners by @Xaositek in https://github.com/meshtastic/firmware/pull/7545\r\n* Improve words within logging for onscreen message scroll cache by @Xaositek in https://github.com/meshtastic/firmware/pull/7548\r\n* Fix: ina226 was not calibrated during init by @mrab in https://github.com/meshtastic/firmware/pull/7547\r\n* Rather than mysteriously rebooting, regenerate the keys and inform the user by @jp-bennett in https://github.com/meshtastic/firmware/pull/7558\r\n* Avoid acquiring lock twice by @oscgonfer in https://github.com/meshtastic/firmware/pull/7555\r\n* Chore(deps): update adafruit shtc3 to v1.0.2 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7557\r\n* Fix a crash on Native reboot by @jp-bennett in https://github.com/meshtastic/firmware/pull/7570\r\n* chore(deps): update meshtastic/device-ui digest to d044c01 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7578\r\n\r\n## New Contributors\r\n* @WOD-MN made their first contribution in https://github.com/meshtastic/firmware/pull/7380\r\n* @barbabarros made their first contribution in https://github.com/meshtastic/firmware/pull/7434\r\n* @valzzu made their first contribution in https://github.com/meshtastic/firmware/pull/7450\r\n* @mikecarper made their first contribution in https://github.com/meshtastic/firmware/pull/7462\r\n* @rradillen made their first contribution in https://github.com/meshtastic/firmware/pull/7363\r\n* @powersjcb made their first contribution in https://github.com/meshtastic/firmware/pull/7505\r\n* @tg-mw made their first contribution in https://github.com/meshtastic/firmware/pull/7524\r\n* @mrab made their first contribution in https://github.com/meshtastic/firmware/pull/7547\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.3.cf574c7...v2.7.4.c1f4f79" - }, - { - "id": "v2.7.3.cf574c7", - "title": "Meshtastic Firmware 2.7.3.cf574c7 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.3.cf574c7", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.3.cf574c7/firmware-esp32-2.7.3.cf574c7.zip", - "release_notes": "## 🚀 Enhancements\r\n* Update Screen Wake Default Behavior by @Xaositek in https://github.com/meshtastic/firmware/pull/7282\r\n* Make the shouldWake function always available, and remove the bool by @jp-bennett in https://github.com/meshtastic/firmware/pull/7300\r\n* Shorter audio feedback for InkHUD buttons by @todd-herbert in https://github.com/meshtastic/firmware/pull/7301\r\n* Support native configuration Waveshare Pico LoRa module on Orange Pi Zero3 by @Mictronics in https://github.com/meshtastic/firmware/pull/7295\r\n* Load ringtone from userPrefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/7298\r\n* Seesaw Rotary by @jp-bennett in https://github.com/meshtastic/firmware/pull/7310\r\n* GPS for STM32WL by @Stary2001 in https://github.com/meshtastic/firmware/pull/7297\r\n* Feat: add support for RAK Wismesh Tag hardware platform by @DanielCao0 in https://github.com/meshtastic/firmware/pull/6853\r\n* Message frame New Message Options and Clock / TDeck / Brightness Refinements by @Xaositek in https://github.com/meshtastic/firmware/pull/7344\r\n* BaseUI Updates by @Xaositek in https://github.com/meshtastic/firmware/pull/7358\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Update RadioLib to v7.2.1 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7287\r\n* Update platformio.ini by @Kongduino in https://github.com/meshtastic/firmware/pull/7289\r\n* Update Adafruit BusIO to v1.17.2 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7277\r\n* Update dorny/test-reporter action to v2.1.1 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7284\r\n* Update meshtastic/device-ui digest to 404c6e0 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7302\r\n* Add first config override for Native by @jp-bennett in https://github.com/meshtastic/firmware/pull/7306\r\n* Update meshtastic/device-ui digest to 86a09a7 by @renovate[bot] in https://github.com/meshtastic/firmware/pull/7308\r\n* STM32: Properly ignore OneButton by @vidplace7 in https://github.com/meshtastic/firmware/pull/7311\r\n* Build: Update platformio with `pkg install` by @vidplace7 in https://github.com/meshtastic/firmware/pull/7315\r\n* Bump Framework-native and set version string. by @jp-bennett in https://github.com/meshtastic/firmware/pull/7317\r\n* userPrefs: Set default ringtone nag time by @vidplace7 in https://github.com/meshtastic/firmware/pull/7314\r\n* Remove Ubuntu oracular by @vidplace7 in https://github.com/meshtastic/firmware/pull/7322\r\n* feat: DIY Seeed XIAO nRF52840 + EBYTE E22 variants, pin-compatible with Wio-SX1262 kit by @ndoo in https://github.com/meshtastic/firmware/pull/7105\r\n* feat: New variant esp32c3_super_mini by @ndoo in https://github.com/meshtastic/firmware/pull/7133\r\n* xiao_ble README.md updates by @ndoo in https://github.com/meshtastic/firmware/pull/7283\r\n* fix(device-update.sh): safely filter args without breaking parsing by @NeilHanlon in https://github.com/meshtastic/firmware/pull/7305\r\n* NodeDB.cpp: Fix iOS bluetooth crash by ensuring UINT32_MAX is not used by @Styne13 in https://github.com/meshtastic/firmware/pull/7312\r\n* Improve OLED UI Responsiveness and Force Redraws for Canned message module by @csrutil in https://github.com/meshtastic/firmware/pull/7324\r\n* add pioenv to version string in debug log by @caveman99 in https://github.com/meshtastic/firmware/pull/7328\r\n* PPA: Add Ubuntu Questing (25.10) to daily builds by @vidplace7 in https://github.com/meshtastic/firmware/pull/7329\r\n* get git url part from local repo by @caveman99 in https://github.com/meshtastic/firmware/pull/7331\r\n* Add heap info via standard mallinfo() function for STM32 by @Stary2001 in https://github.com/meshtastic/firmware/pull/7327\r\n* The screen display of the heltec wireless tracker is abnormal. by @Quency-D in https://github.com/meshtastic/firmware/pull/7337\r\n* STM32 PlatformIO cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/7339\r\n* Map report should work over devices which do not have network hardware (with client proxy) by @thebentern in https://github.com/meshtastic/firmware/pull/7341\r\n* Fix L1 EInk HWModel by @thebentern in https://github.com/meshtastic/firmware/pull/7346\r\n* Drop NodeInfo packets if the is_licensed bit doesn't match owner by @jp-bennett in https://github.com/meshtastic/firmware/pull/7361\r\n* Clean up double i2c init/scan code by @caveman99 in https://github.com/meshtastic/firmware/pull/7359\r\n* Add additional Epoch check for time set by @fifieldt in https://github.com/meshtastic/firmware/pull/7375\r\n\r\n## New Contributors\r\n* @NeilHanlon made their first contribution in https://github.com/meshtastic/firmware/pull/7305\r\n* @Styne13 made their first contribution in https://github.com/meshtastic/firmware/pull/7312\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.2.f6d3782...v2.7.3.cf574c7" - }, - { - "id": "v2.7.2.f6d3782", - "title": "Meshtastic Firmware 2.7.2.f6d3782 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.2.f6d3782", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.2.f6d3782/firmware-esp32-2.7.2.f6d3782.zip", - "release_notes": "## 🚀 Enhancements\r\n* Fast fix, remove saving tx power inside limitPower() by @mrekin in https://github.com/meshtastic/firmware/pull/7255\r\n* Show user which Clock Face option is currently elected by @Xaositek in https://github.com/meshtastic/firmware/pull/7271\r\n* Heltec Wireless Paper, VM-E213 Hardware Revisions by @todd-herbert in https://github.com/meshtastic/firmware/pull/7258\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Add HWIDs for T1000-E in DFU mode by @thebentern in https://github.com/meshtastic/firmware/pull/7235\r\n* chore(deps): update meshtastic/device-ui digest to 8c7092c by @renovate in https://github.com/meshtastic/firmware/pull/7238\r\n* Automatically bail user out of displaymode_color when not HAS_TFT by @jp-bennett in https://github.com/meshtastic/firmware/pull/7248\r\n* Don't run bluetooth gerFromRadio() unless the phone has requested a packet by @jp-bennett in https://github.com/meshtastic/firmware/pull/7231\r\n* Try-fix: L76K spamming bad times can crash nodes by @thebentern in https://github.com/meshtastic/firmware/pull/7261\r\n* Fix install script by @Pitel in https://github.com/meshtastic/firmware/pull/7259\r\n* Modules and favorite screen fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/7264\r\n* TFT_MESH Fixes Across Various Devices by @Xaositek in https://github.com/meshtastic/firmware/pull/7247\r\n* Update Bluetooth Toggle to match other variants by @Xaositek in https://github.com/meshtastic/firmware/pull/7269\r\n* Make PacketHistory logging less chatty by @thebentern in https://github.com/meshtastic/firmware/pull/7272\r\n* GitHub Actions faster!! (again) by @vidplace7 in https://github.com/meshtastic/firmware/pull/7268\r\n* Whoops! Re-Add nRF52 OTA zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/7275\r\n* Actions: Re-Add nrf52 hex release (rak4631) by @vidplace7 in https://github.com/meshtastic/firmware/pull/7276\r\n* Update Adafruit INA260 to v1.5.3 by @renovate in https://github.com/meshtastic/firmware/pull/7270\r\n\r\n## New Contributors\r\n* @Pitel made their first contribution in https://github.com/meshtastic/firmware/pull/7259\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.1.f35ca81...v2.7.2.f6d3782" - }, - { - "id": "v2.7.1.f35ca81", - "title": "Meshtastic Firmware 2.7.1.f35ca81 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.1.f35ca81", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.1.f35ca81/firmware-esp32-2.7.1.f35ca81.zip", - "release_notes": "## 🚀 Enhancements\r\n* Add detection code for SCD4X by @fifieldt in https://github.com/meshtastic/firmware/pull/7185\r\n* Refactor Calibrate battery curve for TRACKER_T1000-E by @Dylanliacc in https://github.com/meshtastic/firmware/pull/7186\r\n* Add detection framework for multiple AirQuality sensors by @fifieldt in https://github.com/meshtastic/firmware/pull/7187\r\n* Log TX power after limits applyng and store it in config by @mrekin in https://github.com/meshtastic/firmware/pull/7065\r\n* Limited emoji support for InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/7176\r\n* Calibrate battery curve for SEEED_WIO_TRACKER_L1 and SEEED_SOLAR_NODE by @Dylanliacc in https://github.com/meshtastic/firmware/pull/7194\r\n* Enable telemetry and I2C sensors on STM32WL (except accelerometers) by @Stary2001 in https://github.com/meshtastic/firmware/pull/7008\r\n* Update protobufs and classes by @github-actions in https://github.com/meshtastic/firmware/pull/7199\r\n* Feat: add support for RAK3312 (New RAKwireless wiscore ESP32-S3 + SX1262) by @DanielCao0 in https://github.com/meshtastic/firmware/pull/7115\r\n* Added option to invert screen on InkHUD by @razurac in https://github.com/meshtastic/firmware/pull/7075\r\n* mDNS: Remove HTTP/HTTPS. Advertise shortname/id. by @vidplace7 in https://github.com/meshtastic/firmware/pull/7162\r\n* Add customizable boot logo based on resolution by @vidplace7 in https://github.com/meshtastic/firmware/pull/7146\r\n* Additional larger font for InkHUD UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/7201\r\n* Add GPIO edge for Native Trackball/Joystick by @jp-bennett in https://github.com/meshtastic/firmware/pull/7212\r\n* Add ROUTER_LATE to EVENT_MODE rules by @vidplace7 in https://github.com/meshtastic/firmware/pull/7220\r\n* Honor custom userPrefs boot-screens in InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/7217\r\n* Battery Layout Updates and Icons Changes by @Xaositek in https://github.com/meshtastic/firmware/pull/7221\r\n* Add Kazakhstan frequencies by @fifieldt in https://github.com/meshtastic/firmware/pull/7209\r\n* Add Kazakhstan to the BaseUI LoRa chooser by @jp-bennett in https://github.com/meshtastic/firmware/pull/7224\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Last second fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/7156\r\n* Add check for theoretically impossible comparison, and drop nodenum by @jp-bennett in https://github.com/meshtastic/firmware/pull/7165\r\n* fix(xiao_ble): Define xiao_ble I2C pins in parent variant (fixes #7163) by @ndoo in https://github.com/meshtastic/firmware/pull/7164\r\n* Bump release version by @github-actions in https://github.com/meshtastic/firmware/pull/7155\r\n* chore(deps): update meshtastic/device-ui digest to 4b7bf36 by @renovate in https://github.com/meshtastic/firmware/pull/7178\r\n* Fix hydra radio by @jankowski-t in https://github.com/meshtastic/firmware/pull/7192\r\n* Fix seeed_tracker_L1_eink bug of cant't switch between two applets side-by-side by @Dylanliacc in https://github.com/meshtastic/firmware/pull/7195\r\n* Fix build when MESHTASTIC_EXCLUDE_GPS is defined by @Mictronics in https://github.com/meshtastic/firmware/pull/7154\r\n* Renovate comment for sensirion/Sensirion I2C SCD4x by @vidplace7 in https://github.com/meshtastic/firmware/pull/7202\r\n* Chore(deps): update sensirion i2c scd4x to v1.1.0 by @renovate in https://github.com/meshtastic/firmware/pull/7207\r\n* 2.7 fixes w2 by @jp-bennett in https://github.com/meshtastic/firmware/pull/7148\r\n* Fix Seeed L1 board to enable consistent PIO flashing by @thebentern in https://github.com/meshtastic/firmware/pull/7211\r\n* Don't set non-existent pin on e290 by @jp-bennett in https://github.com/meshtastic/firmware/pull/7213\r\n* Disable low brightness, as this soft-bricks at least the L1 by @jp-bennett in https://github.com/meshtastic/firmware/pull/7223\r\n* Fixed --change-mode option in device-update.sh by @mattster98 in https://github.com/meshtastic/firmware/pull/7144\r\n* chore(deps): update meshtastic-esp32_https_server digest to 3223704 by @renovate in https://github.com/meshtastic/firmware/pull/7225\r\n* Chore(deps): update xpowerslib to v0.3.0 by @renovate in https://github.com/meshtastic/firmware/pull/7210\r\n* Add a WiFi menu that can toggle back to Bluetooth by @jp-bennett in https://github.com/meshtastic/firmware/pull/7226\r\n\r\n## New Contributors\r\n* @jankowski-t made their first contribution in https://github.com/meshtastic/firmware/pull/7192\r\n* @razurac made their first contribution in https://github.com/meshtastic/firmware/pull/7075\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.0.705515a...v2.7.1.f35ca81" - }, - { - "id": "v2.6.13.0561f2c", - "title": "Meshtastic Firmware 2.6.13.0561f2c Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.13.0561f2c", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.13.0561f2c/firmware-esp32-2.6.13.0561f2c.zip", - "release_notes": "> [!CAUTION] \r\n> This firmware includes built-in security which automatically removes your public key (for direct messages and remote administration) if it is included in any of the 25 known insecure keys in order to keep your communications private. \r\n\r\n## 🚀 Enhancements \r\n* PacketHistory debloat RAM allocations by @Marek-mk in https://github.com/meshtastic/firmware/pull/7034\r\n* Add recognition for SHT40 with serial number starting with 0xc8d by @notmarek in https://github.com/meshtastic/firmware/pull/7061\r\n* Add rak12035 VB Soil Monitor Tested & Working by @Justin-Mann in https://github.com/meshtastic/firmware/pull/6741\r\n* XIAO BLE cleanup (supporting changes to seeed_xiao_nrf52840_kit too) by @ndoo in https://github.com/meshtastic/firmware/pull/7024\r\n* PacketHistory - option to track entries' aging to log by @Marek-mk in https://github.com/meshtastic/firmware/pull/7067\r\n\r\n## 🐛 Bug fixes and enhancements\r\n* Fix Critical Error #3 for LilyGo T-Echo by @Logicbloke in https://github.com/meshtastic/firmware/pull/6791\r\n* Dismiss ExternalNotification nagging on InkHUD button press by @todd-herbert in https://github.com/meshtastic/firmware/pull/7056\r\n* Fix RCWL9620Sensor for rak11310 support by @Nivek-domo in https://github.com/meshtastic/firmware/pull/6617\r\n* Run daily packaging earlier (PPA) by @vidplace7 in https://github.com/meshtastic/firmware/pull/7057\r\n* Ensure incoming hostMetrics userstring is null terminated by @jp-bennett in https://github.com/meshtastic/firmware/pull/7068\r\n* Update HostMetrics.cpp - don't try to print the user string by @jp-bennett in https://github.com/meshtastic/firmware/pull/7081\r\n* Replace blocking delay for wifi reconnect with non-blocking to keep button/display interactivity by @mattster98 in https://github.com/meshtastic/firmware/pull/6983\r\n* Fix position exchange throttling issue by @jeremiah-k in https://github.com/meshtastic/firmware/pull/7079\r\n* Fix nugget s3 lora variant issues by @hafu in https://github.com/meshtastic/firmware/pull/7070\r\n\r\n## New Contributors\r\n* @Logicbloke made their first contribution in https://github.com/meshtastic/firmware/pull/6791\r\n* @Marek-mk made their first contribution in https://github.com/meshtastic/firmware/pull/7034\r\n* @notmarek made their first contribution in https://github.com/meshtastic/firmware/pull/7061\r\n* @mattster98 made their first contribution in https://github.com/meshtastic/firmware/pull/6983\r\n* @hafu made their first contribution in https://github.com/meshtastic/firmware/pull/7070\r\n* @Justin-Mann made their first contribution in https://github.com/meshtastic/firmware/pull/6741\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.12.9861e82...v2.6.13.0561f2c" - }, - { - "id": "v2.6.12.9861e82", - "title": "Meshtastic Firmware 2.6.12.9861e82 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.12.9861e82", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.12.9861e82/firmware-esp32-2.6.12.9861e82.zip", - "release_notes": "> [!CAUTION] \r\n> This firmware includes built-in security which automatically removes your public key (for direct messages and remote administration) if it is included in any of the 25 known insecure keys in order to keep your communications private. \r\n\r\n## 🚀 Enhancements \r\n* Key erase by @jp-bennett in https://github.com/meshtastic/firmware/pull/7018\r\n* Remove GPS Baudrate locking for Seeed Xiao NRF52840 Kit by @fifieldt in https://github.com/meshtastic/firmware/pull/7016\r\n* Validate short and long names so whitespace or empty names cannot be used by @Crank-Git in https://github.com/meshtastic/firmware/pull/6993\r\n* Add InkHUD driver for WeAct Studio 2.13\" display module by @todd-herbert in https://github.com/meshtastic/firmware/pull/7001\r\n* Add InkHUD driver for WeAct Studio 1.54\" display module by @todd-herbert in https://github.com/meshtastic/firmware/pull/7000\r\n* Add support for GAT562 Mesh Trial Tracker by @csrutil in https://github.com/meshtastic/firmware/pull/6984\r\n* Add config for RAK 13300 on RAK6421 by @jp-bennett in https://github.com/meshtastic/firmware/pull/7037\r\n* InkHUD DIY builds for ProMicro & Heltec T114 by @todd-herbert in https://github.com/meshtastic/firmware/pull/7039\r\n* Allow overriding INA3221 channels by @andyshinn in https://github.com/meshtastic/firmware/pull/7035\r\n* Add the ability to share ignored contacts (for blacklisting problematic nodes) by @thebentern in https://github.com/meshtastic/firmware/pull/7044\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* More low-entropy keys, and don't issue a false warning when changing … by @jp-bennett in https://github.com/meshtastic/firmware/pull/7041\r\n* Don't use assert() with side effects in a couple more places by @Stary2001 in https://github.com/meshtastic/firmware/pull/7009\r\n* chore(deps): update platform-native digest to 49634e9 by @renovate in https://github.com/meshtastic/firmware/pull/7020\r\n* chore(deps): update platform-native digest to 681ee02 by @renovate in https://github.com/meshtastic/firmware/pull/7022\r\n* chore(deps): update meshtastic/device-ui digest to 301f11e by @renovate in https://github.com/meshtastic/firmware/pull/7042\r\n* Upgrade trunk by @github-actions in https://github.com/meshtastic/firmware/pull/7030\r\n* Update protobufs and classes by @github-actions in https://github.com/meshtastic/firmware/pull/7043\r\n* Manual bump metainfo version by @vidplace7 in https://github.com/meshtastic/firmware/pull/7049\r\n\r\n## New Contributors\r\n* @csrutil made their first contribution in https://github.com/meshtastic/firmware/pull/6984\r\n* @andyshinn made their first contribution in https://github.com/meshtastic/firmware/pull/7035\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.11.60ec05e...v2.6.12.9861e82" - }, - { - "id": "v2.6.9.f223b8a", - "title": "Meshtastic Firmware 2.6.9.f223b8a Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.9.f223b8a", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.9.f223b8a/firmware-esp32-2.6.9.f223b8a.zip", - "release_notes": "\r\n## Enhancements\r\n* Stop the madness! Run as a user (not root) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6718\r\n* Host metrics by @jp-bennett in https://github.com/meshtastic/firmware/pull/6817\r\n* Hostmetrics user string by @jp-bennett in https://github.com/meshtastic/firmware/pull/6850\r\n* Add basic handling for is_manually_validated by @jp-bennett in https://github.com/meshtastic/firmware/pull/6856\r\n* If a contact is add from a QR, it's \"verified\" manually by @thebentern in https://github.com/meshtastic/firmware/pull/6858\r\n* InkHUD Extended ASCII by @todd-herbert in https://github.com/meshtastic/firmware/pull/6768\r\n* Added map report precision bounds by @thebentern in https://github.com/meshtastic/firmware/pull/6862\r\n* Update Adafruit PM25 AQI Sensor to v2 by @renovate in https://github.com/meshtastic/firmware/pull/6778\r\n* Add PCT2075 Temperature Sensor by @mich181189 in https://github.com/meshtastic/firmware/pull/6829\r\n* Add heap metrics to Local stats by @thebentern in https://github.com/meshtastic/firmware/pull/6887\r\n* Feat(RadioInterface): Tx power gain calculation rework by @ndoo in https://github.com/meshtastic/firmware/pull/6796\r\n\r\n## Bug fixes and maintenance\r\n* Remove compass boot calibration by @jp-bennett in https://github.com/meshtastic/firmware/pull/6825\r\n* Update dorny/test-reporter action to v2.1.0 by @renovate in https://github.com/meshtastic/firmware/pull/6833\r\n* Fix for ICM-20948 not initializing by @HarukiToreda in https://github.com/meshtastic/firmware/pull/6827\r\n* Graphics: Add GDEY0213B74 E-Ink display driver by @chihosin in https://github.com/meshtastic/firmware/pull/6879\r\n* chore(deps): update meshtastic/device-ui digest to e63b219 by @renovate in https://github.com/meshtastic/firmware/pull/6883\r\n* Fix is_unmessagable plumbing by @thebentern in https://github.com/meshtastic/firmware/pull/6886\r\n* InkHUD: add missing parsing of UTF-8 chars by @todd-herbert in https://github.com/meshtastic/firmware/pull/6889\r\n* Update seeed solar node led pin by @Dylanliacc in https://github.com/meshtastic/firmware/pull/6871\r\n* Bosch bsec2: Switch back to official releases by @vidplace7 in https://github.com/meshtastic/firmware/pull/6870\r\n\r\n## New Contributors\r\n* @chihosin made their first contribution in https://github.com/meshtastic/firmware/pull/6879\r\n* @mich181189 made their first contribution in https://github.com/meshtastic/firmware/pull/6829\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.8.ef9d0d7...v2.6.9.f223b8a" - }, - { - "id": "v2.6.8.ef9d0d7", - "title": "Meshtastic Firmware 2.6.8.ef9d0d7 Alpha", - "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7", - "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip", - "release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7" - } - ] - }, - "pullRequests": [] -} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/AppIntroduction.kt b/app/src/main/java/com/geeksville/mesh/AppIntroduction.kt new file mode 100644 index 000000000..f15bec7b8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/AppIntroduction.kt @@ -0,0 +1,61 @@ +package com.geeksville.mesh + +import android.os.Bundle +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import com.geeksville.mesh.model.UIViewModel +import com.github.appintro.AppIntro +import com.github.appintro.AppIntroFragment + +class AppIntroduction : AppIntro() { + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Make sure you don't call setContentView! + + // Call addSlide passing your Fragments. + // You can use AppIntroFragment to use a pre-built fragment + addSlide( + AppIntroFragment.createInstance( + title = resources.getString(R.string.intro_welcome), + description = resources.getString(R.string.intro_welcome_text), + imageDrawable = R.mipmap.ic_launcher2_round, + backgroundColorRes = R.color.colourGrey, + descriptionColorRes = R.color.colorOnPrimary + )) + addSlide(AppIntroFragment.createInstance( + title = resources.getString(R.string.intro_started), + description = resources.getString(R.string.intro_started_text), + imageDrawable = R.drawable.icon_meanings, + backgroundColorRes = R.color.colourGrey, + descriptionColorRes = R.color.colorOnPrimary + )) + addSlide(AppIntroFragment.createInstance( + title = resources.getString(R.string.intro_encryption), + description = resources.getString(R.string.intro_encryption_text), + imageDrawable = R.drawable.channel_name_image, + backgroundColorRes = R.color.colourGrey, + descriptionColorRes = R.color.colorOnPrimary + )) + //addSlide(SlideTwoFragment()) + } + + private fun done() { + val prefs = UIViewModel.getPreferences(this) + prefs.edit { putBoolean("app_intro_completed", true) } + finish() + } + + override fun onSkipPressed(currentFragment: Fragment?) { + super.onSkipPressed(currentFragment) + // Decide what to do when the user clicks on "Skip" + done() + } + + override fun onDonePressed(currentFragment: Fragment?) { + super.onDonePressed(currentFragment) + // Decide what to do when the user clicks on "Done" + done() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt new file mode 100644 index 000000000..b84d8b553 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt @@ -0,0 +1,31 @@ +package com.geeksville.mesh + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@InstallIn(SingletonComponent::class) +@Module +object ApplicationModule { + @Provides + fun provideSharedPreferences(application: Application): SharedPreferences { + return application.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) + } + + @Provides + fun provideProcessLifecycleOwner(): LifecycleOwner { + return ProcessLifecycleOwner.get() + } + + @Provides + fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle { + return processLifecycleOwner.lifecycle + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt new file mode 100644 index 000000000..9922b19eb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/CoroutineDispatchers.kt @@ -0,0 +1,15 @@ +package com.geeksville.mesh + +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +/** + * Wrapper around `Dispatchers` to allow for easier testing when using dispatchers + * in injected classes. + */ +class CoroutineDispatchers @Inject constructor() { + val main = Dispatchers.Main + val mainImmediate = Dispatchers.Main.immediate + val default = Dispatchers.Default + val io = Dispatchers.IO +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt new file mode 100644 index 000000000..2cc10d29d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -0,0 +1,186 @@ +package com.geeksville.mesh + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Generic [Parcel.readParcelable] Android 13 compatibility extension. + */ +private inline fun Parcel.readParcelableCompat(loader: ClassLoader?): T? { + return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + readParcelable(loader) + } else { + readParcelable(loader, T::class.java) + } +} + +@Parcelize +enum class MessageStatus : Parcelable { + UNKNOWN, // Not set for this message + RECEIVED, // Came in from the mesh + QUEUED, // Waiting to send to the mesh as soon as we connect to the device + ENROUTE, // Delivered to the radio, but no ACK or NAK received + DELIVERED, // We received an ack + ERROR // We received back a nak, message not delivered +} + +/** + * A parcelable version of the protobuf MeshPacket + Data subpacket. + */ +@Serializable +data class DataPacket( + var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast + val bytes: ByteArray?, + val dataType: Int, // A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions) + var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost + var time: Long = System.currentTimeMillis(), // msecs since 1970 + var id: Int = 0, // 0 means unassigned + var status: MessageStatus? = MessageStatus.UNKNOWN, + var hopLimit: Int = 0, + var channel: Int = 0, // channel index +) : Parcelable { + + /** + * If there was an error with this message, this string describes what was wrong. + */ + var errorMessage: String? = null + + /** + * Syntactic sugar to make it easy to create text messages + */ + constructor(to: String?, channel: Int, text: String) : this( + to = to, + bytes = text.encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + channel = channel + ) + + /** + * If this is a text message, return the string, otherwise null + */ + val text: String? + get() = if (dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) { + bytes?.decodeToString() + } else { + null + } + + constructor(to: String?, channel: Int, waypoint: MeshProtos.Waypoint) : this( + to = to, + bytes = waypoint.toByteArray(), + dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, + channel = channel + ) + + val waypoint: MeshProtos.Waypoint? + get() = if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) { + MeshProtos.Waypoint.parseFrom(bytes) + } else { + null + } + + // Autogenerated comparision, because we have a byte array + + constructor(parcel: Parcel) : this( + parcel.readString(), + parcel.createByteArray(), + parcel.readInt(), + parcel.readString(), + parcel.readLong(), + parcel.readInt(), + parcel.readParcelableCompat(MessageStatus::class.java.classLoader), + parcel.readInt(), + parcel.readInt(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataPacket + + if (from != other.from) return false + if (to != other.to) return false + if (channel != other.channel) return false + if (time != other.time) return false + if (id != other.id) return false + if (dataType != other.dataType) return false + if (!bytes!!.contentEquals(other.bytes!!)) return false + if (status != other.status) return false + if (hopLimit != other.hopLimit) return false + + return true + } + + override fun hashCode(): Int { + var result = from.hashCode() + result = 31 * result + to.hashCode() + result = 31 * result + time.hashCode() + result = 31 * result + id + result = 31 * result + dataType + result = 31 * result + bytes!!.contentHashCode() + result = 31 * result + status.hashCode() + result = 31 * result + hopLimit + result = 31 * result + channel + return result + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(to) + parcel.writeByteArray(bytes) + parcel.writeInt(dataType) + parcel.writeString(from) + parcel.writeLong(time) + parcel.writeInt(id) + parcel.writeParcelable(status, flags) + parcel.writeInt(hopLimit) + parcel.writeInt(channel) + } + + override fun describeContents(): Int { + return 0 + } + + // Update our object from our parcel (used for inout parameters + fun readFromParcel(parcel: Parcel) { + to = parcel.readString() + parcel.createByteArray() + parcel.readInt() + from = parcel.readString() + time = parcel.readLong() + id = parcel.readInt() + status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader) + hopLimit = parcel.readInt() + channel = parcel.readInt() + } + + companion object CREATOR : Parcelable.Creator { + // Special node IDs that can be used for sending messages + + /** the Node ID for broadcast destinations */ + const val ID_BROADCAST = "^all" + + /** The Node ID for the local node - used for from when sender doesn't know our local node ID */ + const val ID_LOCAL = "^local" + + // special broadcast address + const val NODENUM_BROADCAST = (0xffffffff).toInt() + + // Public-key cryptography (PKC) channel index + const val PKC_CHANNEL_INDEX = 8 + + fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n) + fun idToDefaultNodeNum(id: String?): Int? = id?.toLong(16)?.toInt() + + override fun createFromParcel(parcel: Parcel): DataPacket { + return DataPacket(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt new file mode 100644 index 000000000..dcdea088b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -0,0 +1,782 @@ +package com.geeksville.mesh + +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.content.* +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.hardware.usb.UsbManager +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.text.method.LinkMovementMethod +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.geeksville.mesh.android.* +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.databinding.ActivityMainBinding +import com.geeksville.mesh.model.BluetoothViewModel +import com.geeksville.mesh.model.DeviceVersion +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.primaryChannel +import com.geeksville.mesh.model.shouldAddChannels +import com.geeksville.mesh.model.toChannelSet +import com.geeksville.mesh.service.* +import com.geeksville.mesh.ui.* +import com.geeksville.mesh.ui.map.MapFragment +import com.geeksville.mesh.util.Exceptions +import com.geeksville.mesh.util.LanguageUtils +import com.geeksville.mesh.util.getPackageInfoCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import java.text.DateFormat +import java.util.Date +import javax.inject.Inject + +/* +UI design + +material setup instructions: https://material.io/develop/android/docs/getting-started/ +dark theme (or use system eventually) https://material.io/develop/android/theming/dark/ + +NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app +title. + +Fragments: + +SettingsFragment shows "Settings" + username + shortname + bluetooth pairing list + (eventually misc device settings that are not channel related) + +Channel fragment "Channel" + qr code, copy link button + ch number + misc other settings + (eventually a way of choosing between past channels) + +ChatFragment "Messages" + a text box to enter new texts + a scrolling list of rows. each row is a text and a sender info layout + +NodeListFragment "Users" + a node info row for every node + +ViewModels: + + BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle) + + MeshModel contains: (manages entire service relationship) + current received texts + current radio macaddr + current node infos (updated dynamically) + +eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/ + use numbers of # chat messages and # of members in the badges. + +(per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs ) + + +eventually: + make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder +*/ + +@AndroidEntryPoint +class MainActivity : AppCompatActivity(), Logging { + + private lateinit var binding: ActivityMainBinding + + // Used to schedule a coroutine in the GUI thread + private val mainScope = CoroutineScope(Dispatchers.Main + Job()) + + private val bluetoothViewModel: BluetoothViewModel by viewModels() + private val model: UIViewModel by viewModels() + + @Inject + internal lateinit var serviceRepository: ServiceRepository + + private val bluetoothPermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (result.entries.all { it.value }) { + info("Bluetooth permissions granted") + } else { + warn("Bluetooth permissions denied") + showSnackbar(permissionMissing) + } + requestedEnable = false + bluetoothViewModel.permissionsUpdated() + } + + private val notificationPermissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (result.entries.all { it.value }) { + info("Notification permissions granted") + } else { + warn("Notification permissions denied") + showSnackbar(getString(R.string.notification_denied), Snackbar.LENGTH_SHORT) + } + } + + data class TabInfo(val text: String, val icon: Int, val content: Fragment) + + private val tabInfos = arrayOf( + TabInfo( + "Messages", + R.drawable.ic_twotone_message_24, + ContactsFragment() + ), + TabInfo( + "Users", + R.drawable.ic_twotone_people_24, + UsersFragment() + ), + TabInfo( + "Map", + R.drawable.ic_twotone_map_24, + MapFragment() + ), + TabInfo( + "Channel", + R.drawable.ic_twotone_contactless_24, + ChannelFragment() + ), + TabInfo( + "Settings", + R.drawable.ic_twotone_settings_applications_24, + SettingsFragment() + ) + ) + + private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) { + override fun getItemCount(): Int = tabInfos.size + override fun createFragment(position: Int): Fragment = tabInfos[position].content + } + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + val prefs = UIViewModel.getPreferences(this) + // First run: migrate in-app language prefs to appcompat + val lang = prefs.getString("lang", LanguageUtils.SYSTEM_DEFAULT) + if (lang != LanguageUtils.SYSTEM_MANAGED) LanguageUtils.migrateLanguagePrefs(prefs) + info("in-app language is ${LanguageUtils.getLocale()}") + // Set theme + AppCompatDelegate.setDefaultNightMode( + prefs.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + ) + // First run: show AppIntroduction + if (!prefs.getBoolean("app_intro_completed", false)) { + startActivity(Intent(this, AppIntroduction::class.java)) + } + // Ask user to rate in play store + (application as GeeksvilleApplication).askToRate(this) + } + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + initToolbar() + + binding.pager.adapter = tabsAdapter + binding.pager.isUserInputEnabled = + false // Gestures for screen switching doesn't work so good with the map view + // pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops + TabLayoutMediator(binding.tabLayout, binding.pager, false, false) { tab, position -> + // tab.text = tabInfos[position].text // I think it looks better with icons only + tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon) + }.attach() + + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + val mainTab = tab?.position ?: 0 + model.setCurrentTab(mainTab) + } + override fun onTabUnselected(tab: TabLayout.Tab?) { } + override fun onTabReselected(tab: TabLayout.Tab?) { } + }) + + // Handle any intent + handleIntent(intent) + } + + private fun initToolbar() { + val toolbar = binding.toolbar as Toolbar + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + } + + private fun updateConnectionStatusImage(connected: MeshService.ConnectionState) { + if (model.actionBarMenu == null) return + + val (image, tooltip) = when (connected) { + MeshService.ConnectionState.CONNECTED -> R.drawable.cloud_on to R.string.connected + MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24 to R.string.device_sleeping + MeshService.ConnectionState.DISCONNECTED -> R.drawable.cloud_off to R.string.disconnected + } + + val item = model.actionBarMenu?.findItem(R.id.connectStatusImage) + if (item != null) { + item.setIcon(image) + item.setTitle(tooltip) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private var requestedChannelUrl: Uri? = null + + // Handle any intents that were passed into us + private fun handleIntent(intent: Intent) { + val appLinkAction = intent.action + val appLinkData: Uri? = intent.data + + when (appLinkAction) { + Intent.ACTION_VIEW -> { + debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio") + requestedChannelUrl = appLinkData + + // if the device is connected already, process it now + perhapsChangeChannel() + + // We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel + } + + MeshServiceNotifications.OPEN_MESSAGE_ACTION -> { + val contactKey = + intent.getStringExtra(MeshServiceNotifications.OPEN_MESSAGE_EXTRA_CONTACT_KEY) + val contactName = + intent.getStringExtra(MeshServiceNotifications.OPEN_MESSAGE_EXTRA_CONTACT_NAME) + showMessages(contactKey, contactName) + } + + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + showSettingsPage() + } + + Intent.ACTION_MAIN -> { + } + + else -> { + warn("Unexpected action $appLinkAction") + } + } + } + + private var requestedEnable = false + private val bleRequestEnable = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + requestedEnable = false + } + + private val createDocumentLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) } + } + } + + override fun onDestroy() { + mainScope.cancel("Activity going away") + super.onDestroy() + } + + /** Show an alert that may contain HTML */ + private fun showAlert(titleText: Int, messageText: Int) { + + // make links clickable per https://stackoverflow.com/a/62642807 + // val messageStr = getText(messageText) + + val builder = MaterialAlertDialogBuilder(this) + .setCancelable(false) + .setTitle(titleText) + .setMessage(messageText) + .setPositiveButton(R.string.okay) { _, _ -> + info("User acknowledged") + } + + val dialog = builder.show() + + // Make the textview clickable. Must be called after show() + val view = (dialog.findViewById(android.R.id.message) as TextView?)!! + // Linkify.addLinks(view, Linkify.ALL) // not needed with this method + view.movementMethod = LinkMovementMethod.getInstance() + + showSettingsPage() // Default to the settings page in this case + } + + // Called when we gain/lose a connection to our mesh radio + private fun onMeshConnectionChanged(newConnection: MeshService.ConnectionState) { + if (newConnection == MeshService.ConnectionState.CONNECTED) { + serviceRepository.meshService?.let { service -> + try { + val info: MyNodeInfo? = service.myNodeInfo // this can be null + + if (info != null) { + val isOld = info.minAppVersion > BuildConfig.VERSION_CODE + if (isOld) { + showAlert(R.string.app_too_old, R.string.must_update) + } else { + // If we are already doing an update don't put up a dialog or try to get device info + val isUpdating = service.updateStatus >= 0 + if (!isUpdating) { + val curVer = DeviceVersion(info.firmwareVersion ?: "0.0.0") + + if (curVer < MeshService.minDeviceVersion) { + showAlert(R.string.firmware_too_old, R.string.firmware_old) + } else { + // If our app is too old/new, we probably don't understand the new DeviceConfig messages, so we don't read them until here + + // we have a connection to our device now, do the channel change + perhapsChangeChannel() + } + } + } + } + } catch (ex: RemoteException) { + warn("Abandoning connect $ex, because we probably just lost device connection") + } + // if provideLocation enabled: Start providing location (from phone GPS) to mesh + if (model.provideLocation.value == true) { + service.startProvideLocation() + } + } + + if (!hasNotificationPermission()) { + val notificationPermissions = getNotificationPermissions() + rationaleDialog( + shouldShowRequestPermissionRationale(notificationPermissions), + R.string.notification_required, + getString(R.string.why_notification_required), + ) { + notificationPermissionsLauncher.launch(notificationPermissions) + } + } + } + } + + private fun showSnackbar(msgId: Int) { + try { + Snackbar.make(binding.root, msgId, Snackbar.LENGTH_LONG).show() + } catch (ex: IllegalStateException) { + errormsg("Snackbar couldn't find view for msgId $msgId") + } + } + + private fun showSnackbar(msg: String, duration: Int = Snackbar.LENGTH_INDEFINITE) { + try { + Snackbar.make(binding.root, msg, duration) + .apply { view.findViewById(R.id.snackbar_text).isSingleLine = false } + .setAction(R.string.okay) { + // dismiss + } + .show() + } catch (ex: IllegalStateException) { + errormsg("Snackbar couldn't find view for msgString $msg") + } + } + + @Suppress("NestedBlockDepth") + private fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) { + // if the device is connected already, process it now + if (url != null && model.isConnected()) { + requestedChannelUrl = null + try { + val channels = url.toChannelSet() + val shouldAdd = url.shouldAddChannels() + val primary = channels.primaryChannel + if (primary == null) { + showSnackbar(R.string.channel_invalid) + } else { + val dialogMessage = if (!shouldAdd) { + getString(R.string.do_you_want_switch).format(primary.name) + } else { + resources.getQuantityString( + R.plurals.add_channel_from_qr, + channels.settingsCount, + channels.settingsCount + ) + } + MaterialAlertDialogBuilder(this) + .setTitle(R.string.new_channel_rcvd) + .setMessage(dialogMessage) + .setNeutralButton(R.string.cancel) { _, _ -> + // Do nothing + } + .setPositiveButton(R.string.accept) { _, _ -> + debug("Setting channel from URL") + try { + model.setChannels(channels, !shouldAdd) + } catch (ex: RemoteException) { + errormsg("Couldn't change channel ${ex.message}") + showSnackbar(R.string.cant_change_no_radio) + } + } + .show() + } + } catch (ex: Throwable) { + errormsg("Channel url error: ${ex.message}") + showSnackbar("${getString(R.string.channel_invalid)}: ${ex.message}") + } + } + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + return try { + super.dispatchTouchEvent(ev) + } catch (ex: Throwable) { + Exceptions.report( + ex, + "dispatchTouchEvent" + ) // hide this Compose error from the user but report to the mothership + false + } + } + + private var connectionJob: Job? = null + + private val mesh = object : + ServiceClient({ + IMeshService.Stub.asInterface(it) + }) { + override fun onConnected(service: IMeshService) { + connectionJob = mainScope.handledLaunch { + serviceRepository.setMeshService(service) + + try { + val connectionState = + MeshService.ConnectionState.valueOf(service.connectionState()) + + // We won't receive a notify for the initial state of connection, so we force an update here + onMeshConnectionChanged(connectionState) + } catch (ex: RemoteException) { + errormsg("Device error during init ${ex.message}") + } finally { + connectionJob = null + } + + debug("connected to mesh service, connectionState=${model.connectionState.value}") + } + } + + override fun onDisconnected() { + serviceRepository.setMeshService(null) + } + } + + private fun bindMeshService() { + debug("Binding to mesh service!") + // we bind using the well known name, to make sure 3rd party apps could also + if (serviceRepository.meshService != null) { + /* This problem can occur if we unbind, but there is already an onConnected job waiting to run. That job runs and then makes meshService != null again + I think I've fixed this by cancelling connectionJob. We'll see! + */ + Exceptions.reportError("meshService was supposed to be null, ignoring (but reporting a bug)") + } + + try { + MeshService.startService(this) // Start the service so it stays running even after we unbind + } catch (ex: Exception) { + // Old samsung phones have a race condition andthis might rarely fail. Which is probably find because the bind will be sufficient most of the time + errormsg("Failed to start service from activity - but ignoring because bind will work ${ex.message}") + } + + // ALSO bind so we can use the api + mesh.connect( + this, + MeshService.createIntent(), + Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT + ) + } + + private fun unbindMeshService() { + // If we have received the service, and hence registered with + // it, then now is the time to unregister. + // if we never connected, do nothing + debug("Unbinding from mesh service!") + connectionJob?.let { job -> + connectionJob = null + warn("We had a pending onConnection job, so we are cancelling it") + job.cancel("unbinding") + } + mesh.close() + serviceRepository.setMeshService(null) + } + + override fun onStop() { + unbindMeshService() + super.onStop() + } + + override fun onStart() { + super.onStart() + + model.connectionState.observe(this) { state -> + onMeshConnectionChanged(state) + updateConnectionStatusImage(state) + } + + bluetoothViewModel.enabled.observe(this) { enabled -> + if (!enabled && !requestedEnable && model.selectedBluetooth) { + requestedEnable = true + if (hasBluetoothPermission()) { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + bleRequestEnable.launch(enableBtIntent) + } else { + val bluetoothPermissions = getBluetoothPermissions() + rationaleDialog(shouldShowRequestPermissionRationale(bluetoothPermissions)) { + bluetoothPermissionsLauncher.launch(bluetoothPermissions) + } + } + } + } + + // Call perhapsChangeChannel() whenever [requestChannelUrl] updates with a non-null value + model.requestChannelUrl.observe(this) { url -> + url?.let { + requestedChannelUrl = url + model.clearRequestChannelUrl() + perhapsChangeChannel() + } + } + + // Call showSnackbar() whenever [snackbarText] updates with a non-null value + model.snackbarText.observe(this) { text -> + if (text is Int) showSnackbar(text) + if (text is String) showSnackbar(text) + if (text != null) model.clearSnackbarText() + } + + model.currentTab.observe(this) { + binding.tabLayout.getTabAt(it)?.select() + } + + model.tracerouteResponse.observe(this) { response -> + MaterialAlertDialogBuilder(this) + .setCancelable(false) + .setTitle(R.string.traceroute) + .setMessage(response ?: return@observe) + .setPositiveButton(R.string.okay) { _, _ -> } + .show() + + model.clearTracerouteResponse() + } + + try { + bindMeshService() + } catch (ex: BindFailedException) { + // App is probably shutting down, ignore + errormsg("Bind of MeshService failed") + } + + val bonded = model.bondedAddress != null + if (!bonded) showSettingsPage() + } + + private fun showSettingsPage() { + binding.pager.currentItem = 5 + } + + private fun showMessages(contactKey: String?, contactName: String?) { + model.setCurrentTab(0) + if (contactKey != null && contactName != null) { + supportFragmentManager.navigateToMessages(contactKey, contactName) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.menu_main, menu) + model.actionBarMenu = menu + + updateConnectionStatusImage(model.connectionState.value!!) + + return true + } + + private val handler: Handler by lazy { + Handler(Looper.getMainLooper()) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.findItem(R.id.stress_test).isVisible = + BuildConfig.DEBUG // only show stress test for debug builds (for now) + menu.findItem(R.id.radio_config).isEnabled = !model.isManaged + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + return when (item.itemId) { + R.id.about -> { + getVersionInfo() + return true + } + R.id.connectStatusImage -> { + Toast.makeText(applicationContext, item.title, Toast.LENGTH_SHORT).show() + return true + } + R.id.debug -> { + val fragmentManager: FragmentManager = supportFragmentManager + val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction() + val nameFragment = DebugFragment() + fragmentTransaction.add(R.id.mainActivityLayout, nameFragment) + fragmentTransaction.addToBackStack(null) + fragmentTransaction.commit() + return true + } + R.id.stress_test -> { + fun postPing() { + // Send ping message and arrange delayed recursion. + debug("Sending ping") + val str = "Ping " + DateFormat.getTimeInstance(DateFormat.MEDIUM) + .format(Date(System.currentTimeMillis())) + model.sendMessage(str) + handler.postDelayed({ postPing() }, 30000) + } + item.isChecked = !item.isChecked // toggle ping test + if (item.isChecked) { + postPing() + } else { + handler.removeCallbacksAndMessages(null) + } + return true + } + R.id.radio_config -> { + supportFragmentManager.navigateToNavGraph() + return true + } + R.id.save_messages_csv -> { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, "rangetest.csv") + } + createDocumentLauncher.launch(intent) + return true + } + R.id.theme -> { + chooseThemeDialog() + return true + } + R.id.preferences_language -> { + chooseLangDialog() + return true + } + R.id.show_intro -> { + startActivity(Intent(this, AppIntroduction::class.java)) + return true + } + R.id.preferences_quick_chat -> { + val fragmentManager: FragmentManager = supportFragmentManager + val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction() + val nameFragment = QuickChatSettingsFragment() + fragmentTransaction.add(R.id.mainActivityLayout, nameFragment) + fragmentTransaction.addToBackStack(null) + fragmentTransaction.commit() + return true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun getVersionInfo() { + try { + val packageInfo: PackageInfo = packageManager.getPackageInfoCompat(packageName, 0) + val versionName = packageInfo.versionName + Toast.makeText(this, versionName, Toast.LENGTH_LONG).show() + } catch (e: PackageManager.NameNotFoundException) { + errormsg("Can not find the version: ${e.message}") + } + } + + // Theme functions + + private fun chooseThemeDialog() { + + // Prepare dialog and its items + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(getString(R.string.choose_theme)) + + val styles = mapOf( + getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO, + getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES, + getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ) + + // Load preferences and its value + val prefs = UIViewModel.getPreferences(this) + val theme = prefs.getInt("theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + debug("Theme from prefs: $theme") + + builder.setSingleChoiceItems( + styles.keys.toTypedArray(), + styles.values.indexOf(theme) + ) { dialog, position -> + val selectedTheme = styles.values.elementAt(position) + debug("Set theme pref to $selectedTheme") + prefs.edit().putInt("theme", selectedTheme).apply() + AppCompatDelegate.setDefaultNightMode(selectedTheme) + dialog.dismiss() + } + + val dialog = builder.create() + dialog.show() + } + + private fun chooseLangDialog() { + // Prepare dialog and its items + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(getString(R.string.preferences_language)) + + val languageTags = LanguageUtils.getLanguageTags(this) + + // Load preferences and its value + val lang = LanguageUtils.getLocale() + debug("Lang from prefs: $lang") + + builder.setSingleChoiceItems( + languageTags.keys.toTypedArray(), + languageTags.values.indexOf(lang) + ) { dialog, position -> + val selectedLang = languageTags.values.elementAt(position) + debug("Set lang pref to $selectedLang") + LanguageUtils.setLocale(selectedLang) + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt new file mode 100644 index 000000000..66ee73c92 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/MyNodeInfo.kt @@ -0,0 +1,25 @@ +package com.geeksville.mesh + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +// MyNodeInfo sent via special protobuf from radio +@Parcelize +data class MyNodeInfo( + val myNodeNum: Int, + val hasGPS: Boolean, + val model: String?, + val firmwareVersion: String?, + val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want + val shouldUpdate: Boolean, // this device has old firmware + val currentPacketId: Long, + val messageTimeoutMsec: Int, + val minAppVersion: Int, + val maxChannels: Int, + val hasWifi: Boolean, + val channelUtilization: Float, + val airUtilTx: Float +) : Parcelable { + /** A human readable description of the software/hardware version */ + val firmwareString: String get() = "$model $firmwareVersion" +} diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt new file mode 100644 index 000000000..670053eda --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -0,0 +1,224 @@ +package com.geeksville.mesh + +import android.graphics.Color +import android.os.Parcelable +import com.geeksville.mesh.util.GPSFormat +import com.geeksville.mesh.util.bearing +import com.geeksville.mesh.util.latLongToMeter +import com.geeksville.mesh.util.anonymize +import kotlinx.parcelize.Parcelize + +// +// model objects that directly map to the corresponding protobufs +// + +@Parcelize +data class MeshUser( + val id: String, + val longName: String, + val shortName: String, + val hwModel: MeshProtos.HardwareModel, + val isLicensed: Boolean = false, + val role: Int = 0, +) : Parcelable { + + override fun toString(): String { + return "MeshUser(id=${id.anonymize}, " + + "longName=${longName.anonymize}, " + + "shortName=${shortName.anonymize}, " + + "hwModel=$hwModelString, " + + "isLicensed=$isLicensed, " + + "role=$role)" + } + + /** Create our model object from a protobuf. + */ + constructor(p: MeshProtos.User) : this( + p.id, + p.longName, + p.shortName, + p.hwModel, + p.isLicensed, + p.roleValue + ) + + /** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot + * or null if unset + * */ + val hwModelString: String? + get() = + if (hwModel == MeshProtos.HardwareModel.UNSET) null + else hwModel.name.replace('_', '-').replace('p', '.').lowercase() +} + +@Parcelize +data class Position( + val latitude: Double, + val longitude: Double, + val altitude: Int, + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val satellitesInView: Int = 0, + val groundSpeed: Int = 0, + val groundTrack: Int = 0, // "heading" + val precisionBits: Int = 0, +) : Parcelable { + + companion object { + /// Convert to a double representation of degrees + fun degD(i: Int) = i * 1e-7 + fun degI(d: Double) = (d * 1e7).toInt() + + fun currentTime() = (System.currentTimeMillis() / 1000).toInt() + } + + /** Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will be used. + */ + constructor(position: MeshProtos.Position, defaultTime: Int = currentTime()) : this( + // We prefer the int version of lat/lon but if not available use the depreciated legacy version + degD(position.latitudeI), + degD(position.longitudeI), + position.altitude, + if (position.time != 0) position.time else defaultTime, + position.satsInView, + position.groundSpeed, + position.groundTrack, + position.precisionBits + ) + + /// @return distance in meters to some other node (or null if unknown) + fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude) + + /// @return bearing to the other position in degrees + fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude) + + // If GPS gives a crap position don't crash our app + fun isValid(): Boolean { + return latitude != 0.0 && longitude != 0.0 && + (latitude >= -90 && latitude <= 90.0) && + (longitude >= -180 && longitude <= 180) + } + + fun gpsString(gpsFormat: Int): String = when (gpsFormat) { + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.DMS(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.UTM(this) + ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.MGRS(this) + else -> GPSFormat.DEC(this) + } + + override fun toString(): String { + return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})" + } +} + + +@Parcelize +data class DeviceMetrics( + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val batteryLevel: Int = 0, + val voltage: Float, + val channelUtilization: Float, + val airUtilTx: Float, + val uptimeSeconds: Int, +) : Parcelable { + companion object { + fun currentTime() = (System.currentTimeMillis() / 1000).toInt() + } + + /** Create our model object from a protobuf. + */ + constructor(p: TelemetryProtos.DeviceMetrics, telemetryTime: Int = currentTime()) : this( + telemetryTime, + p.batteryLevel, + p.voltage, + p.channelUtilization, + p.airUtilTx, + p.uptimeSeconds, + ) +} + +@Parcelize +data class EnvironmentMetrics( + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val temperature: Float, + val relativeHumidity: Float, + val barometricPressure: Float, + val gasResistance: Float, + val voltage: Float, + val current: Float, + val iaq: Int, +) : Parcelable { + companion object { + fun currentTime() = (System.currentTimeMillis() / 1000).toInt() + } +} + +@Parcelize +data class NodeInfo( + val num: Int, // This is immutable, and used as a key + var user: MeshUser? = null, + var position: Position? = null, + var snr: Float = Float.MAX_VALUE, + var rssi: Int = Int.MAX_VALUE, + var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 + var deviceMetrics: DeviceMetrics? = null, + var channel: Int = 0, + var environmentMetrics: EnvironmentMetrics? = null, + var hopsAway: Int = 0 +) : Parcelable { + + val colors: Pair + get() { // returns foreground and background @ColorInt for each 'num' + val r = (num and 0xFF0000) shr 16 + val g = (num and 0x00FF00) shr 8 + val b = num and 0x0000FF + val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 + return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b) + } + + val batteryLevel get() = deviceMetrics?.batteryLevel + val voltage get() = deviceMetrics?.voltage + val batteryStr get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else "" + + /** + * true if the device was heard from recently + */ + val isOnline: Boolean + get() { + val now = System.currentTimeMillis() / 1000 + val timeout = 15 * 60 + return (now - lastHeard <= timeout) + } + + /// return the position if it is valid, else null + val validPosition: Position? + get() { + return position?.takeIf { it.isValid() } + } + + /// @return distance in meters to some other node (or null if unknown) + fun distance(o: NodeInfo?): Int? { + val p = validPosition + val op = o?.validPosition + return if (p != null && op != null) p.distance(op).toInt() else null + } + + /// @return bearing to the other position in degrees + fun bearing(o: NodeInfo?): Int? { + val p = validPosition + val op = o?.validPosition + return if (p != null && op != null) p.bearing(op).toInt() else null + } + + /// @return a nice human readable string for the distance, or null for unknown + fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist -> + when { + dist == 0 -> null // same point + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble()) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.1f km".format(dist / 1000.0) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281) + prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34) + else -> null + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/analytics/AnalyticsClient.kt b/app/src/main/java/com/geeksville/mesh/analytics/AnalyticsClient.kt new file mode 100644 index 000000000..a54a95251 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/analytics/AnalyticsClient.kt @@ -0,0 +1,36 @@ +package com.geeksville.mesh.analytics + +/** + * Created by kevinh on 12/24/14. + */ +interface AnalyticsProvider { + + // Turn analytics logging on/off + fun setEnabled(on: Boolean) + + /** + * Store an event + */ + fun track(event: String, vararg properties: DataPair) + + /** + * Only track this event if using a cheap provider (like google) + */ + fun trackLowValue(event: String, vararg properties: DataPair) + + fun endSession() + fun startSession() + + /** + * Set persistent ID info about this user, as a key value pair + */ + fun setUserInfo(vararg p: DataPair) + + /** + * Increment some sort of analytics counter + */ + fun increment(name: String, amount: Double = 1.0) + + fun sendScreenView(name: String) + fun endScreenView() +} diff --git a/app/src/main/java/com/geeksville/mesh/android/AppPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/AppPrefs.kt new file mode 100644 index 000000000..dc147bc60 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/AppPrefs.kt @@ -0,0 +1,110 @@ +package com.geeksville.mesh.android + +import android.content.Context +import android.content.SharedPreferences +import java.util.UUID +import kotlin.reflect.KProperty + +/** + * Created by kevinh on 1/4/15. + */ + + +/** + * A delegate for "foo by FloatPref" + */ +class FloatPref { + fun get(thisRef: AppPrefs, prop: KProperty): Float = thisRef.getPrefs().getFloat(thisRef.makeName(prop.name), java.lang.Float.MIN_VALUE) + + fun set(thisRef: AppPrefs, prop: KProperty, value: Float) { + thisRef.setPrefs { e -> e.putFloat(thisRef.makeName(prop.name), value)} + } +} + +/** + * A delegate for "foo by StringPref" + */ +class StringPref(val default: String) { + fun get(thisRef: AppPrefs, prop: KProperty): String = thisRef.getPrefs().getString(thisRef.makeName(prop.name), default)!! + + fun set(thisRef: AppPrefs, prop: KProperty, value: String) { + thisRef.setPrefs { e -> + e.putString(thisRef.makeName(prop.name), value) + } + } +} + +/** + * A mixin for accessing android prefs for the app + */ +public open class AppPrefs(val context: Context) { + + companion object { + private val baseName = "appPrefs_" + } + + fun makeName(s: String) = baseName + s + + fun getPrefs() = context.getSharedPreferences("prefs", Context.MODE_PRIVATE) + + fun setPrefs(body: (SharedPreferences.Editor) -> Unit) { + val e = getPrefs().edit() + body(e) + e.commit() + } + + fun incPref(name: String) { + setPrefs { e -> + e.putInt(name, 1 + getPrefs().getInt(name, 0)) + } + } + + fun removePref(name: String) { + setPrefs { e -> + e.remove(name) + } + } + + fun putPref(name: String, b: Boolean) { + setPrefs { e -> + e.putBoolean(name, b) + } + } + + fun putPref(name: String, b: Float) { + setPrefs { e -> + e.putFloat(name, b) + } + } + + fun putPref(name: String, b: Int) { + setPrefs { e -> + e.putInt(name, b) + } + } + + fun putPref(name: String, b: Set) { + setPrefs { e -> + e.putStringSet(name, b) + } + } + + fun putPref(name: String, b: String) { + setPrefs { e -> + e.putString(name, b) + } + } + + /** + * Return a persistent installation ID + */ + fun getInstallId(): String { + var r = getPrefs().getString(makeName("installId"), "")!! + if(r == "") { + r = UUID.randomUUID().toString() + putPref(makeName("installId"), r) + } + + return r + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/android/BuildUtils.kt b/app/src/main/java/com/geeksville/mesh/android/BuildUtils.kt new file mode 100644 index 000000000..87f4814ba --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/BuildUtils.kt @@ -0,0 +1,33 @@ +package com.geeksville.mesh.android + +import android.os.Build + +/** + * Created by kevinh on 1/14/16. + */ +object BuildUtils : Logging { + + fun is64Bit(): Boolean { + if (Build.VERSION.SDK_INT < 21) + return false + else + return Build.SUPPORTED_64_BIT_ABIS.size > 0 + } + + fun isBuggyMoto(): Boolean { + debug("Device type is: ${Build.DEVICE}") + return Build.DEVICE == "osprey_u2" // Moto G + } + + // Are we running on the emulator? + val isEmulator + get() = Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.startsWith("unknown") || + Build.FINGERPRINT.contains("emulator") || + setOf(Build.MODEL, Build.PRODUCT).contains("google_sdk") || + Build.MODEL.contains("sdk_gphone64") || + Build.MODEL.contains("Emulator") || + Build.MODEL.contains("Android SDK built for") || + Build.MANUFACTURER.contains("Genymotion") || + Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") +} diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextExtensions.kt b/app/src/main/java/com/geeksville/mesh/android/ContextExtensions.kt new file mode 100644 index 000000000..219585f3c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/ContextExtensions.kt @@ -0,0 +1,20 @@ +package com.geeksville.mesh.android + +import android.app.Activity +import android.content.Context +import android.view.inputmethod.InputMethodManager +import android.widget.Toast + +/// show a toast +fun Context.toast(message: CharSequence) = + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + +/// Utility function to hide the soft keyboard per stack overflow +fun Activity.hideKeyboard() { + // Check if no view has focus: + currentFocus?.let { v -> + val imm = + getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(v.windowToken, 0) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt new file mode 100644 index 000000000..551195ec4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt @@ -0,0 +1,161 @@ +package com.geeksville.mesh.android + +import android.Manifest +import android.app.Activity +import android.app.NotificationManager +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.geeksville.mesh.R +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +/** + * @return null on platforms without a BlueTooth driver (i.e. the emulator) + */ +val Context.bluetoothManager: BluetoothManager? + get() = getSystemService(Context.BLUETOOTH_SERVICE).takeIf { hasBluetoothPermission() } as? BluetoothManager? + +val Context.notificationManager: NotificationManager get() = requireNotNull(getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager?) + +val Context.locationManager: LocationManager get() = requireNotNull(getSystemService(Context.LOCATION_SERVICE) as? LocationManager?) + +/** + * @return true if the device has a GPS receiver + */ +fun Context.hasGps(): Boolean = locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) + +/** + * @return true if the device has a GPS receiver and it is disabled (location turned off) + */ +fun Context.gpsDisabled(): Boolean = + if (hasGps()) !locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) else false + +/** + * @return the text string of the permissions missing + */ +val Context.permissionMissing: String + get() = if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + getString(R.string.permission_missing) + } else { + getString(R.string.permission_missing_31) + } + +/** + * Checks if any given permissions need to show rationale. + * + * @return true if should show UI with rationale before requesting a permission. + */ +fun Activity.shouldShowRequestPermissionRationale(permissions: Array): Boolean { + for (permission in permissions) { + if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) { + return true + } + } + return false +} + +/** + * Checks if any given permissions need to show rationale. + * + * @return true if should show UI with rationale before requesting a permission. + */ +fun Fragment.shouldShowRequestPermissionRationale(permissions: Array): Boolean { + for (permission in permissions) { + if (shouldShowRequestPermissionRationale(permission)) { + return true + } + } + return false +} + +/** + * Handles whether a rationale dialog should be shown before performing an action. + */ +fun Context.rationaleDialog( + shouldShowRequestPermissionRationale: Boolean = true, + title: Int = R.string.required_permissions, + rationale: CharSequence = permissionMissing, + invokeFun: () -> Unit, +) { + if (!shouldShowRequestPermissionRationale) invokeFun() + else MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(rationale) + .setNeutralButton(R.string.cancel) { _, _ -> + } + .setPositiveButton(R.string.accept) { _, _ -> + invokeFun() + } + .show() +} + +/** + * return a list of the permissions we don't have + */ +fun Context.getMissingPermissions(perms: List): Array = perms.filter { + ContextCompat.checkSelfPermission( + this, + it + ) != PackageManager.PERMISSION_GRANTED +}.toTypedArray() + +/** + * Bluetooth permissions (or empty if we already have what we need) + */ +fun Context.getBluetoothPermissions(): Array { + val perms = mutableListOf() + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + perms.add(Manifest.permission.BLUETOOTH_SCAN) + perms.add(Manifest.permission.BLUETOOTH_CONNECT) + } else { + perms.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + return getMissingPermissions(perms) +} + +/** @return true if the user already has Bluetooth connect permission */ +fun Context.hasBluetoothPermission() = getBluetoothPermissions().isEmpty() + +/** + * Camera permission (or empty if we already have what we need) + */ +fun Context.getCameraPermissions(): Array { + val perms = mutableListOf(Manifest.permission.CAMERA) + + return getMissingPermissions(perms) +} + +/** @return true if the user already has camera permission */ +fun Context.hasCameraPermission() = getCameraPermissions().isEmpty() + +/** + * Location permission (or empty if we already have what we need) + */ +fun Context.getLocationPermissions(): Array { + val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION) + + return getMissingPermissions(perms) +} + +/** @return true if the user already has location permission */ +fun Context.hasLocationPermission() = getLocationPermissions().isEmpty() + +/** + * Notification permission (or empty if we already have what we need) + */ +fun Context.getNotificationPermissions(): Array { + val perms = mutableListOf() + if (android.os.Build.VERSION.SDK_INT >= 33) { + perms.add(Manifest.permission.POST_NOTIFICATIONS) + } + + return getMissingPermissions(perms) +} + +/** @return true if the user already has notification permission */ +fun Context.hasNotificationPermission() = getNotificationPermissions().isEmpty() diff --git a/app/src/main/java/com/geeksville/mesh/android/DateUtils.kt b/app/src/main/java/com/geeksville/mesh/android/DateUtils.kt new file mode 100644 index 000000000..ededfa21b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/DateUtils.kt @@ -0,0 +1,14 @@ +package com.geeksville.mesh.android + +import java.util.* + +/** + * Created by kevinh on 1/13/16. + */ +object DateUtils { + fun dateUTC(year: Int, month: Int, day: Int): Date { + val cal = GregorianCalendar(TimeZone.getTimeZone("GMT")) + cal.set(year, month, day, 0, 0, 0); + return Date(cal.getTime().getTime()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt b/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt new file mode 100644 index 000000000..c5b68e4b8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/DebugLogFile.kt @@ -0,0 +1,36 @@ +package com.geeksville.mesh.android + +import android.content.Context +import java.io.File +import java.io.FileOutputStream +import java.io.PrintWriter + +/** + * Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME) + * + * write strings to that file + */ +class DebugLogFile(context: Context, name: String) { + val stream = FileOutputStream(File(context.getExternalFilesDir(null), name), true) + val file = PrintWriter(stream) + + fun close() { + file.close() + } + + fun log(s: String) { + file.println(s) // FIXME, optionally include timestamps + file.flush() // for debugging + } +} + + +/** + * Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME) + * + * write strings to that file + */ +class BinaryLogFile(context: Context, name: String) : + FileOutputStream(File(context.getExternalFilesDir(null), name), true) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/android/ExpireChecker.kt b/app/src/main/java/com/geeksville/mesh/android/ExpireChecker.kt new file mode 100644 index 000000000..a2ccc482f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/ExpireChecker.kt @@ -0,0 +1,40 @@ +package com.geeksville.mesh.android + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import com.geeksville.mesh.R +import java.util.* + +/** + * Created by kevinh on 1/13/16. + */ +class ExpireChecker(val context: Activity) : Logging { + + fun check(year: Int, month: Int, day: Int) { + val expireDate = DateUtils.dateUTC(year, month, day) + val now = Date() + + debug("Expire check $now vs $expireDate") + if (now.after(expireDate)) + doExpire() + } + + private fun doExpire() { + val packageName = context.packageName + errormsg("$packageName is too old and must be updated at the Play store") + + Toast.makeText( + context, + R.string.app_too_old, + Toast.LENGTH_LONG + ).show() + val i = Intent(Intent.ACTION_VIEW) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + i.setData(Uri.parse("market://details?id=$packageName&referrer=utm_source%3Dexpired")) + context.startActivity(i) + context.finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/android/Logging.kt b/app/src/main/java/com/geeksville/mesh/android/Logging.kt new file mode 100644 index 000000000..036afb7aa --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/Logging.kt @@ -0,0 +1,83 @@ +package com.geeksville.mesh.android + +import android.os.Build +import android.util.Log +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.util.Exceptions + +/** + * Created by kevinh on 12/24/14. + */ + +typealias LogPrinter = (Int, String, String) -> Unit + +interface Logging { + + companion object { + /** Some vendors strip log messages unless the severity is super high. + * + * alps == Soyes + * HMD Global == mfg of the Nokia 7.2 + */ + private val badVendors = setOf("OnePlus", "alps", "HMD Global", "Sony") + + /// if false NO logs will be shown, set this in the application based on BuildConfig.DEBUG + var showLogs = true + + /** if true, all logs will be printed at error level. Sometimes necessary for buggy ROMs + * that filter logcat output below this level. + * + * Since there are so many bad vendors, we just always lie if we are a release build + */ + var forceErrorLevel = !BuildConfig.DEBUG || badVendors.contains(Build.MANUFACTURER) + + /// If false debug logs will not be shown (but others might) + var showDebug = true + + /** + * By default all logs are printed using the standard android Log class. But clients + * can change printlog to a different implementation (for logging to files or via + * google crashlytics) + */ + var printlog: LogPrinter = { level, tag, message -> + if (showLogs) { + if (showDebug || level > Log.DEBUG) { + Log.println(if (forceErrorLevel) Log.ERROR else level, tag, message) + } + } + } + } + + private fun tag(): String = this.javaClass.getName() + + fun info(msg: String) = printlog(Log.INFO, tag(), msg) + fun verbose(msg: String) = printlog(Log.VERBOSE, tag(), msg) + fun debug(msg: String) = printlog(Log.DEBUG, tag(), msg) + fun warn(msg: String) = printlog(Log.WARN, tag(), msg) + + /** + * Log an error message, note - we call this errormsg rather than error because error() is + * a stdlib function in kotlin in the global namespace and we don't want users to accidentally call that. + */ + fun errormsg(msg: String, ex: Throwable? = null) { + if (ex?.message != null) + printlog(Log.ERROR, tag(), "$msg (exception ${ex.message})") + else + printlog(Log.ERROR, tag(), "$msg") + } + + /// Kotlin assertions are disabled on android, so instead we use this assert helper + fun logAssert(f: Boolean) { + if (!f) { + val ex = AssertionError("Assertion failed") + + // if(!Debug.isDebuggerConnected()) + throw ex + } + } + + /// Report an error (including messaging our crash reporter service if allowed + fun reportError(s: String) { + Exceptions.report(Exception("logging reportError: $s"), s) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/android/ServiceClient.kt b/app/src/main/java/com/geeksville/mesh/android/ServiceClient.kt new file mode 100644 index 000000000..92370ccf4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/android/ServiceClient.kt @@ -0,0 +1,115 @@ +package com.geeksville.mesh.android + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.IInterface +import com.geeksville.mesh.util.exceptionReporter +import java.io.Closeable +import java.lang.IllegalArgumentException +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class BindFailedException : Exception("bindService failed") + +/** + * A wrapper that cleans up the service binding process + */ +open class ServiceClient(private val stubFactory: (IBinder) -> T) : Closeable, + Logging { + + var serviceP: T? = null + + // A getter that returns the bound service or throws if not bound + val service: T + get() { + waitConnect() // Wait for at least the initial connection to happen + return serviceP ?: throw Exception("Service not bound") + } + + private var context: Context? = null + + private var isClosed = true + + private val lock = ReentrantLock() + private val condition = lock.newCondition() + + /** Call this if you want to stall until the connection is completed */ + fun waitConnect() { + // Wait until this service is connected + lock.withLock { + if (context == null) { + throw Exception("Haven't called connect") + } + + if (serviceP == null) { + condition.await() + } + } + } + + fun connect(c: Context, intent: Intent, flags: Int) { + context = c + if (isClosed) { + isClosed = false + if (!c.bindService(intent, connection, flags)) { + + // Some phones seem to ahve a race where if you unbind and quickly rebind bindService returns false. Try + // a short sleep to see if that helps + errormsg("Needed to use the second bind attempt hack") + Thread.sleep(500) // was 200ms, but received an autobug from a Galaxy Note4, android 6.0.1 + if (!c.bindService(intent, connection, flags)) { + throw BindFailedException() + } + } + } else { + warn("Ignoring rebind attempt for service") + } + } + + override fun close() { + isClosed = true + try { + context?.unbindService(connection) + } catch (ex: IllegalArgumentException) { + // Autobugs show this can generate an illegal arg exception for "service not registered" during reinstall? + warn("Ignoring error in ServiceClient.close, probably harmless") + } + serviceP = null + context = null + } + + // Called when we become connected + open fun onConnected(service: T) { + } + + // called on loss of connection + open fun onDisconnected() { + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) = exceptionReporter { + if (!isClosed) { + val s = stubFactory(binder) + serviceP = s + onConnected(s) + + // after calling our handler, tell anyone who was waiting for this connection to complete + lock.withLock { + condition.signalAll() + } + } else { + // If we start to close a service, it seems that there is a possibility a onServiceConnected event is the queue + // for us. Be careful not to process that stale event + warn("A service connected while we were closing it, ignoring") + } + } + + override fun onServiceDisconnected(name: ComponentName?) = exceptionReporter { + serviceP = null + onDisconnected() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/concurrent/Coroutines.kt b/app/src/main/java/com/geeksville/mesh/concurrent/Coroutines.kt new file mode 100644 index 000000000..89c652b25 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/concurrent/Coroutines.kt @@ -0,0 +1,29 @@ +package com.geeksville.mesh.concurrent + +import com.geeksville.mesh.util.Exceptions +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +private val errorHandler = + CoroutineExceptionHandler { _, exception -> + Exceptions.report( + exception, + "MeshService-coroutine", + "coroutine-exception" + ) + } + +/// Wrap launch with an exception handler, FIXME, move into a utility lib +fun CoroutineScope.handledLaunch( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit +) = this.launch( + context = context + com.geeksville.mesh.concurrent.errorHandler, + start = start, + block = block +) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/concurrent/DeferredExecution.kt b/app/src/main/java/com/geeksville/mesh/concurrent/DeferredExecution.kt new file mode 100644 index 000000000..d6f061e3c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/concurrent/DeferredExecution.kt @@ -0,0 +1,30 @@ +package com.geeksville.mesh.concurrent + +import com.geeksville.mesh.android.Logging + + +/** + * Sometimes when starting services we face situations where messages come in that require computation + * but we can't do that computation yet because we are still waiting for some long running init to + * complete. + * + * This class lets you queue up closures to run at a later date and later on you can call run() to + * run all the previously queued work. + */ +class DeferredExecution : Logging { + private val queue = mutableListOf<() -> Unit>() + + /// Queue some new work + fun add(fn: () -> Unit) { + queue.add(fn) + } + + /// run all work in the queue and clear it to be ready to accept new work + fun run() { + debug("Running deferred execution numjobs=${queue.size}") + queue.forEach { + it() + } + queue.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt new file mode 100644 index 000000000..f1f6d1490 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt @@ -0,0 +1,83 @@ +package com.geeksville.mesh.concurrent + +import com.geeksville.mesh.android.Logging + +/** + * A deferred execution object (with various possible implementations) + */ +interface Continuation : Logging { + abstract fun resume(res: Result) + + // syntactic sugar + + fun resumeSuccess(res: T) = resume(Result.success(res)) + fun resumeWithException(ex: Throwable) = try { + resume(Result.failure(ex)) + } catch (ex: Throwable) { + // errormsg("Ignoring $ex while resuming, because we are the ones who threw it") + throw ex + } +} + +/** + * An async continuation that just calls a callback when the result is available + */ +class CallbackContinuation(private val cb: (Result) -> Unit) : Continuation { + override fun resume(res: Result) = cb(res) +} + +/** + * This is a blocking/threaded version of coroutine Continuation + * + * A little bit ugly, but the coroutine version has a nasty internal bug that showed up + * in my SyncBluetoothDevice so I needed a quick workaround. + */ +class SyncContinuation : Continuation { + + private val mbox = java.lang.Object() + private var result: Result? = null + + override fun resume(res: Result) { + synchronized(mbox) { + result = res + mbox.notify() + } + } + + // Wait for the result (or throw an exception) + fun await(timeoutMsecs: Long = 0): T { + synchronized(mbox) { + val startT = System.currentTimeMillis() + while (result == null) { + mbox.wait(timeoutMsecs) + + if (timeoutMsecs > 0 && ((System.currentTimeMillis() - startT) >= timeoutMsecs)) { + throw Exception("SyncContinuation timeout") + } + } + + val r = result + if (r != null) { + return r.getOrThrow() + } else { + throw Exception("This shouldn't happen") + } + } + } +} + +/** + * Calls an init function which is responsible for saving our continuation so that some + * other thread can call resume or resume with exception. + * + * Essentially this is a blocking version of the (buggy) coroutine suspendCoroutine + */ +fun suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation) -> Unit): T { + val cont = SyncContinuation() + + // First call the init funct + initfn(cont) + + // Now wait for the continuation to finish + return cont.await(timeoutMsecs) +} diff --git a/app/src/main/java/com/geeksville/mesh/database/Converters.kt b/app/src/main/java/com/geeksville/mesh/database/Converters.kt new file mode 100644 index 000000000..6cc44b9c4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/Converters.kt @@ -0,0 +1,100 @@ +package com.geeksville.mesh.database + +import androidx.room.TypeConverter +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.TelemetryProtos +import com.geeksville.mesh.android.Logging +import com.google.protobuf.InvalidProtocolBufferException +import kotlinx.serialization.json.Json + +@Suppress("TooManyFunctions") +class Converters : Logging { + @TypeConverter + fun dataFromString(value: String): DataPacket { + val json = Json { isLenient = true } + return json.decodeFromString(DataPacket.serializer(), value) + } + + @TypeConverter + fun dataToString(value: DataPacket): String { + val json = Json { isLenient = true } + return json.encodeToString(DataPacket.serializer(), value) + } + + @TypeConverter + fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio { + return try { + MeshProtos.FromRadio.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToFromRadio TypeConverter error:", ex) + MeshProtos.FromRadio.getDefaultInstance() + } + } + + @TypeConverter + fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToUser(bytes: ByteArray): MeshProtos.User { + return try { + MeshProtos.User.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToUser TypeConverter error:", ex) + MeshProtos.User.getDefaultInstance() + } + } + + @TypeConverter + fun userToBytes(value: MeshProtos.User): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToPosition(bytes: ByteArray): MeshProtos.Position { + return try { + MeshProtos.Position.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToPosition TypeConverter error:", ex) + MeshProtos.Position.getDefaultInstance() + } + } + + @TypeConverter + fun positionToBytes(value: MeshProtos.Position): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry { + return try { + TelemetryProtos.Telemetry.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToTelemetry TypeConverter error:", ex) + TelemetryProtos.Telemetry.getDefaultInstance() + } + } + + @TypeConverter + fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount { + return try { + PaxcountProtos.Paxcount.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToPaxcounter TypeConverter error:", ex) + PaxcountProtos.Paxcount.getDefaultInstance() + } + } + + @TypeConverter + fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? { + return value.toByteArray() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt b/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt new file mode 100644 index 000000000..2c2909f2c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.database + +import android.app.Application +import com.geeksville.mesh.database.dao.MeshLogDao +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.dao.QuickChatActionDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class DatabaseModule { + @Provides + @Singleton + fun provideDatabase(app: Application): MeshtasticDatabase = + MeshtasticDatabase.getDatabase(app) + + @Provides + fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao { + return database.nodeInfoDao() + } + + @Provides + fun providePacketDao(database: MeshtasticDatabase): PacketDao { + return database.packetDao() + } + + @Provides + fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao { + return database.meshLogDao() + } + + @Provides + fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao { + return database.quickChatActionDao() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt new file mode 100644 index 000000000..82c77eeea --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -0,0 +1,83 @@ +package com.geeksville.mesh.database + +import com.geeksville.mesh.Portnums +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.database.dao.MeshLogDao +import com.geeksville.mesh.database.entity.MeshLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.Lazy) { + private val meshLogDao by lazy { + meshLogDaoLazy.get() + } + + suspend fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) { + meshLogDao.getAllLogs(maxItems) + } + + suspend fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow> = withContext(Dispatchers.IO) { + meshLogDao.getAllLogsInReceiveOrder(maxItems) + } + + private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { + Telemetry.parseFrom(log.fromRadio.packet.decoded.payload) + .toBuilder().setTime((log.received_date / MILLIS_TO_SECONDS).toInt()).build() + }.getOrNull() + + @OptIn(ExperimentalCoroutinesApi::class) + fun getTelemetryFrom(nodeNum: Int): Flow> = + meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) + .distinctUntilChanged() + .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } + .flowOn(Dispatchers.IO) + + fun getLogsFrom( + nodeNum: Int, + portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, + maxItem: Int = MAX_MESH_PACKETS, + ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem) + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + + /* + * Retrieves MeshPackets matching 'nodeNum' and 'portNum'. + * If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun getMeshPacketsFrom( + nodeNum: Int, + portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, + ): Flow> = getLogsFrom(nodeNum, portNum) + .mapLatest { list -> list.map { it.fromRadio.packet } } + .flowOn(Dispatchers.IO) + + suspend fun insert(log: MeshLog) = withContext(Dispatchers.IO) { + meshLogDao.insert(log) + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + meshLogDao.deleteAll() + } + + suspend fun deleteLog(uuid: String) = withContext(Dispatchers.IO) { + meshLogDao.deleteLog(uuid) + } + + suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(Dispatchers.IO) { + meshLogDao.deleteLogs(nodeNum, portNum) + } + + companion object { + private const val MAX_ITEMS = 500 + private const val MAX_MESH_PACKETS = 10000 + private const val MILLIS_TO_SECONDS = 1000 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt new file mode 100644 index 000000000..6a1542b2d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -0,0 +1,71 @@ +package com.geeksville.mesh.database + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteTable +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.dao.MeshLogDao +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.dao.QuickChatActionDao +import com.geeksville.mesh.database.entity.ContactSettings +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.database.entity.QuickChatAction + +@Database( + entities = [ + MyNodeEntity::class, + NodeEntity::class, + Packet::class, + ContactSettings::class, + MeshLog::class, + QuickChatAction::class + ], + autoMigrations = [ + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), + AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class), + ], + version = 13, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class MeshtasticDatabase : RoomDatabase() { + abstract fun nodeInfoDao(): NodeInfoDao + abstract fun packetDao(): PacketDao + abstract fun meshLogDao(): MeshLogDao + abstract fun quickChatActionDao(): QuickChatActionDao + + companion object { + fun getDatabase(context: Context): MeshtasticDatabase { + + return Room.databaseBuilder( + context.applicationContext, + MeshtasticDatabase::class.java, + "meshtastic_database" + ) + .fallbackToDestructiveMigration() + .build() + } + } +} + +@DeleteTable.Entries( + DeleteTable(tableName = "NodeInfo"), + DeleteTable(tableName = "MyNodeInfo") +) +class AutoMigration12to13 : AutoMigrationSpec diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt new file mode 100644 index 000000000..b3cc3925e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -0,0 +1,88 @@ +package com.geeksville.mesh.database + +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.Portnums.PortNum +import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.entity.ContactSettings +import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Lazy) { + private val packetDao by lazy { + packetDaoLazy.get() + } + + fun getWaypoints(): Flow> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE) + + fun getContacts(): Flow> = packetDao.getContactKeys() + + suspend fun getMessageCount(contact: String): Int = withContext(Dispatchers.IO) { + packetDao.getMessageCount(contact) + } + + suspend fun getUnreadCount(contact: String): Int = withContext(Dispatchers.IO) { + packetDao.getUnreadCount(contact) + } + + suspend fun clearUnreadCount(contact: String, timestamp: Long) = withContext(Dispatchers.IO) { + packetDao.clearUnreadCount(contact, timestamp) + } + + suspend fun getQueuedPackets(): List? = withContext(Dispatchers.IO) { + packetDao.getQueuedPackets() + } + + suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) { + packetDao.insert(packet) + } + + fun getMessagesFrom(contact: String) = packetDao.getMessagesFrom(contact) + + suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) = withContext(Dispatchers.IO) { + packetDao.updateMessageStatus(d, m) + } + + suspend fun updateMessageId(d: DataPacket, id: Int) = withContext(Dispatchers.IO) { + packetDao.updateMessageId(d, id) + } + + suspend fun getPacketById(requestId: Int) = withContext(Dispatchers.IO) { + packetDao.getPacketById(requestId) + } + + suspend fun deleteMessages(uuidList: List) = withContext(Dispatchers.IO) { + for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query + packetDao.deleteMessages(chunk) + } + } + + suspend fun deleteContacts(contactList: List) = withContext(Dispatchers.IO) { + packetDao.deleteContacts(contactList) + } + + suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) { + packetDao.deleteWaypoint(id) + } + + suspend fun delete(packet: Packet) = withContext(Dispatchers.IO) { + packetDao.delete(packet) + } + + suspend fun update(packet: Packet) = withContext(Dispatchers.IO) { + packetDao.update(packet) + } + + fun getContactSettings(): Flow> = packetDao.getContactSettings() + + suspend fun getContactSettings(contact: String) = withContext(Dispatchers.IO) { + packetDao.getContactSettings(contact) ?: ContactSettings(contact) + } + + suspend fun setMuteUntil(contacts: List, until: Long) = withContext(Dispatchers.IO) { + packetDao.setMuteUntil(contacts, until) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt b/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt new file mode 100644 index 000000000..0bef1c756 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt @@ -0,0 +1,38 @@ +package com.geeksville.mesh.database + +import com.geeksville.mesh.database.dao.QuickChatActionDao +import com.geeksville.mesh.database.entity.QuickChatAction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class QuickChatActionRepository @Inject constructor(private val quickChatDaoLazy: dagger.Lazy) { + private val quickChatActionDao by lazy { + quickChatDaoLazy.get() + } + + suspend fun getAllActions(): Flow> = withContext(Dispatchers.IO) { + quickChatActionDao.getAll() + } + + suspend fun insert(action: QuickChatAction) = withContext(Dispatchers.IO) { + quickChatActionDao.insert(action) + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + quickChatActionDao.deleteAll() + } + + suspend fun delete(action: QuickChatAction) = withContext(Dispatchers.IO) { + quickChatActionDao.delete(action) + } + + suspend fun update(action: QuickChatAction) = withContext(Dispatchers.IO) { + quickChatActionDao.update(action) + } + + suspend fun setItemPosition(uuid: Long, newPos: Int) = withContext(Dispatchers.IO) { + quickChatActionDao.updateActionPosition(uuid, newPos) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt new file mode 100644 index 000000000..f11227ff1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt @@ -0,0 +1,42 @@ +package com.geeksville.mesh.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.geeksville.mesh.database.entity.MeshLog +import kotlinx.coroutines.flow.Flow + +@Dao +interface MeshLogDao { + + @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem") + fun getAllLogs(maxItem: Int): Flow> + + @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem") + fun getAllLogsInReceiveOrder(maxItem: Int): Flow> + + /* + * Retrieves MeshPackets matching 'from_num' (nodeNum) and 'port_num' (PortNum). + * If 'portNum' is 0, returns all MeshPackets. Otherwise, filters by 'port_num'. + */ + @Query( + """ + SELECT * FROM log + WHERE from_num = :fromNum AND (:portNum = 0 AND port_num != 0 OR port_num = :portNum) + ORDER BY received_date DESC LIMIT 0,:maxItem + """ + ) + fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> + + @Insert + fun insert(log: MeshLog) + + @Query("DELETE FROM log") + fun deleteAll() + + @Query("DELETE FROM log WHERE uuid = :uuid") + fun deleteLog(uuid: String) + + @Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum") + fun deleteLogs(fromNum: Int, portNum: Int) +} diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt new file mode 100644 index 000000000..b7a568f42 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -0,0 +1,95 @@ +package com.geeksville.mesh.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.MapColumn +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface NodeInfoDao { + + @Query("SELECT * FROM my_node") + fun getMyNodeInfo(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun setMyNodeInfo(myInfo: MyNodeEntity) + + @Query("DELETE FROM my_node") + fun clearMyNodeInfo() + + @Query( + """ + SELECT * FROM nodes + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 + ELSE 1 + END, + last_heard DESC + """ + ) + fun nodeDBbyNum(): Flow> + + @Query( + """ + WITH OurNode AS ( + SELECT latitude, longitude + FROM nodes + WHERE num = (SELECT myNodeNum FROM my_node LIMIT 1) + ) + SELECT * FROM nodes + WHERE (:includeUnknown = 1 OR short_name IS NOT NULL) + AND (:filter = '' + OR (long_name LIKE '%' || :filter || '%' + OR short_name LIKE '%' || :filter || '%')) + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 + ELSE 1 + END, + CASE + WHEN :sort = 'last_heard' THEN last_heard * -1 + WHEN :sort = 'alpha' THEN UPPER(long_name) + WHEN :sort = 'distance' THEN + CASE + WHEN latitude IS NULL OR longitude IS NULL OR + (latitude = 0.0 AND longitude = 0.0) THEN 999999999 + ELSE + (latitude - (SELECT latitude FROM OurNode)) * + (latitude - (SELECT latitude FROM OurNode)) + + (longitude - (SELECT longitude FROM OurNode)) * + (longitude - (SELECT longitude FROM OurNode)) + END + WHEN :sort = 'hops_away' THEN + CASE + WHEN hops_away = -1 THEN 999999999 + ELSE hops_away + END + WHEN :sort = 'channel' THEN channel + WHEN :sort = 'via_mqtt' THEN long_name LIKE '%(MQTT)' -- viaMqtt + ELSE 0 + END ASC, + last_heard DESC + """ + ) + fun getNodes( + sort: String, + filter: String, + includeUnknown: Boolean, + ): Flow> + + @Upsert + fun upsert(node: NodeEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun putAll(nodes: List) + + @Query("DELETE FROM nodes") + fun clearNodeInfo() + + @Query("DELETE FROM nodes WHERE num=:num") + fun deleteNode(num: Int) +} diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt new file mode 100644 index 000000000..8b0ef8fcd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -0,0 +1,180 @@ +package com.geeksville.mesh.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.MapColumn +import androidx.room.Update +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.database.entity.ContactSettings +import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.flow.Flow + +@Dao +interface PacketDao { + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = :portNum + ORDER BY received_time ASC + """ + ) + fun getAllPackets(portNum: Int): Flow> + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 + ORDER BY received_time DESC + """ + ) + fun getContactKeys(): Flow> + + @Query( + """ + SELECT COUNT(*) FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact + """ + ) + suspend fun getMessageCount(contact: String): Int + + @Query( + """ + SELECT COUNT(*) FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact AND read = 0 + """ + ) + suspend fun getUnreadCount(contact: String): Int + + @Query( + """ + UPDATE packet + SET read = 1 + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact AND read = 0 AND received_time <= :timestamp + """ + ) + suspend fun clearUnreadCount(contact: String, timestamp: Long) + + @Insert + fun insert(packet: Packet) + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact + ORDER BY received_time DESC + """ + ) + fun getMessagesFrom(contact: String): Flow> + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND data = :data + """ + ) + fun findDataPacket(data: DataPacket): Packet? + + @Query("DELETE FROM packet WHERE uuid in (:uuidList)") + fun deleteMessages(uuidList: List) + + @Query( + """ + DELETE FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND contact_key IN (:contactList) + """ + ) + fun deleteContacts(contactList: List) + + @Query("DELETE FROM packet WHERE uuid=:uuid") + fun _delete(uuid: Long) + + @Transaction + fun delete(packet: Packet) { + _delete(packet.uuid) + } + + @Update + fun update(packet: Packet) + + @Transaction + fun updateMessageStatus(data: DataPacket, m: MessageStatus) { + val new = data.copy(status = m) + findDataPacket(data)?.let { update(it.copy(data = new)) } + } + + @Transaction + fun updateMessageId(data: DataPacket, id: Int) { + val new = data.copy(id = id) + findDataPacket(data)?.let { update(it.copy(data = new)) } + } + + @Query( + """ + SELECT data FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + ORDER BY received_time ASC + """ + ) + fun getDataPackets(): List + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND packet_id = :requestId + ORDER BY received_time DESC + """ + ) + fun getPacketById(requestId: Int): Packet? + + @Transaction + fun getQueuedPackets(): List? = + getDataPackets().filter { it.status == MessageStatus.QUEUED } + + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 8 + ORDER BY received_time ASC + """ + ) + fun getAllWaypoints(): List + + @Transaction + fun deleteWaypoint(id: Int) { + val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid } + deleteMessages(uuidList) + } + + @Query("SELECT * FROM contact_settings") + fun getContactSettings(): Flow> + + @Query("SELECT * FROM contact_settings WHERE contact_key = :contact") + suspend fun getContactSettings(contact: String): ContactSettings? + + @Upsert + fun upsertContactSettings(contacts: List) + + @Transaction + suspend fun setMuteUntil(contacts: List, until: Long) { + val contactList = contacts.map { contact -> + getContactSettings(contact)?.copy(muteUntil = until) + ?: ContactSettings(contact_key = contact, muteUntil = until) + } + upsertContactSettings(contactList) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt new file mode 100644 index 000000000..8af931323 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt @@ -0,0 +1,37 @@ +package com.geeksville.mesh.database.dao + +import androidx.room.* +import com.geeksville.mesh.database.entity.QuickChatAction +import kotlinx.coroutines.flow.Flow + +@Dao +interface QuickChatActionDao { + + @Query("Select * from quick_chat order by position asc") + fun getAll(): Flow> + + @Insert + fun insert(action: QuickChatAction) + + @Query("Delete from quick_chat") + fun deleteAll() + + @Query("Delete from quick_chat where uuid=:uuid") + fun _delete(uuid: Long) + + @Transaction + fun delete(action: QuickChatAction) { + _delete(action.uuid) + decrementPositionsAfter(action.position) + } + + @Update + fun update(action: QuickChatAction) + + @Query("Update quick_chat set position=:position WHERE uuid=:uuid") + fun updateActionPosition(uuid: Long, position: Int) + + @Query("Update quick_chat set position=position-1 where position>=:position") + fun decrementPositionsAfter(position: Int) + +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/MeshLog.kt b/app/src/main/java/com/geeksville/mesh/database/entity/MeshLog.kt new file mode 100644 index 000000000..73c92d3ee --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/MeshLog.kt @@ -0,0 +1,79 @@ +package com.geeksville.mesh.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshProtos.FromRadio +import com.geeksville.mesh.Portnums +import com.google.protobuf.TextFormat +import java.io.IOException + +@Entity( + tableName = "log", + indices = [ + Index(value = ["from_num"]), + Index(value = ["port_num"]), + ], +) +data class MeshLog( + @PrimaryKey val uuid: String, + @ColumnInfo(name = "type") val message_type: String, + @ColumnInfo(name = "received_date") val received_date: Long, + @ColumnInfo(name = "message") val raw_message: String, + @ColumnInfo(name = "from_num", defaultValue = "0") val fromNum: Int = 0, + @ColumnInfo(name = "port_num", defaultValue = "0") val portNum: Int = 0, + @ColumnInfo(name = "from_radio", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''") + val fromRadio: FromRadio = FromRadio.getDefaultInstance(), +) { + + val meshPacket: MeshProtos.MeshPacket? + get() { + if (message_type == "Packet") { + val builder = MeshProtos.MeshPacket.newBuilder() + try { + TextFormat.getParser().merge(raw_message, builder) + return builder.build() + } catch (e: IOException) { + } + } + return null + } + + val nodeInfo: MeshProtos.NodeInfo? + get() { + if (message_type == "NodeInfo") { + val builder = MeshProtos.NodeInfo.newBuilder() + try { + TextFormat.getParser().merge(raw_message, builder) + return builder.build() + } catch (e: IOException) { + } + } + return null + } + + val myNodeInfo: MeshProtos.MyNodeInfo? + get() { + if (message_type == "MyNodeInfo") { + val builder = MeshProtos.MyNodeInfo.newBuilder() + try { + TextFormat.getParser().merge(raw_message, builder) + return builder.build() + } catch (e: IOException) { + } + } + return null + } + + val position: MeshProtos.Position? + get() { + return meshPacket?.run { + if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) { + return MeshProtos.Position.parseFrom(decoded.payload) + } + return null + } ?: nodeInfo?.position + } +} diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/MyNodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/MyNodeEntity.kt new file mode 100644 index 000000000..b14ccc309 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/MyNodeEntity.kt @@ -0,0 +1,39 @@ +package com.geeksville.mesh.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.geeksville.mesh.MyNodeInfo + +@Entity(tableName = "my_node") +data class MyNodeEntity( + @PrimaryKey(autoGenerate = false) + val myNodeNum: Int, + val model: String?, + val firmwareVersion: String?, + val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want + val shouldUpdate: Boolean, // this device has old firmware + val currentPacketId: Long, + val messageTimeoutMsec: Int, + val minAppVersion: Int, + val maxChannels: Int, + val hasWifi: Boolean, +) { + /** A human readable description of the software/hardware version */ + val firmwareString: String get() = "$model $firmwareVersion" + + fun toMyNodeInfo() = MyNodeInfo( + myNodeNum = myNodeNum, + hasGPS = false, + model = model, + firmwareVersion = firmwareVersion, + couldUpdate = couldUpdate, + shouldUpdate = shouldUpdate, + currentPacketId = currentPacketId, + messageTimeoutMsec = messageTimeoutMsec, + minAppVersion = minAppVersion, + maxChannels = maxChannels, + hasWifi = hasWifi, + channelUtilization = 0f, + airUtilTx = 0f, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt new file mode 100644 index 000000000..b4d3e7d44 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt @@ -0,0 +1,238 @@ +package com.geeksville.mesh.database.entity + +import android.graphics.Color +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import com.geeksville.mesh.DeviceMetrics +import com.geeksville.mesh.EnvironmentMetrics +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.Position +import com.geeksville.mesh.TelemetryProtos +import com.geeksville.mesh.copy +import com.geeksville.mesh.util.bearing +import com.geeksville.mesh.util.GPSFormat +import com.geeksville.mesh.util.latLongToMeter +import com.geeksville.mesh.util.toDistanceString +import com.google.protobuf.ByteString + +@Suppress("MagicNumber") +@Entity(tableName = "nodes") +data class NodeEntity( + + @PrimaryKey(autoGenerate = false) + val num: Int, // This is immutable, and used as a key + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + @ColumnInfo(name = "long_name") var longName: String? = null, + @ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(), + var latitude: Double = 0.0, + var longitude: Double = 0.0, + + var snr: Float = Float.MAX_VALUE, + var rssi: Int = Int.MAX_VALUE, + + @ColumnInfo(name = "last_heard") + var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 + + @ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) + var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + var channel: Int = 0, + + @ColumnInfo(name = "via_mqtt") + var viaMqtt: Boolean = false, + + @ColumnInfo(name = "hops_away") + var hopsAway: Int = -1, + + @ColumnInfo(name = "is_favorite") + var isFavorite: Boolean = false, + + @ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB) + var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) + var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), +) { + val deviceMetrics: TelemetryProtos.DeviceMetrics + get() = deviceTelemetry.deviceMetrics + + val environmentMetrics: TelemetryProtos.EnvironmentMetrics + get() = environmentTelemetry.environmentMetrics + + val hasEnvironmentMetrics: Boolean + get() = environmentMetrics != TelemetryProtos.EnvironmentMetrics.getDefaultInstance() + + val powerMetrics: TelemetryProtos.PowerMetrics + get() = powerTelemetry.powerMetrics + + val hasPowerMetrics: Boolean + get() = powerMetrics != TelemetryProtos.PowerMetrics.getDefaultInstance() + + val colors: Pair + get() { // returns foreground and background @ColorInt for each 'num' + val r = (num and 0xFF0000) shr 16 + val g = (num and 0x00FF00) shr 8 + val b = num and 0x0000FF + val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 + return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b) + } + + val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET + val hasPKC get() = !user.publicKey.isEmpty + val errorByteString: ByteString get() = ByteString.copyFrom(ByteArray(32) { 0 }) + val mismatchKey get() = user.publicKey == errorByteString + + val batteryLevel get() = deviceMetrics.batteryLevel + val voltage get() = deviceMetrics.voltage + val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" + + fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) { + position = p.copy { time = if (p.time != 0) p.time else defaultTime } + latitude = degD(p.latitudeI) + longitude = degD(p.longitudeI) + } + + private fun hasValidPosition(): Boolean { + return latitude != 0.0 && longitude != 0.0 && + (latitude >= -90 && latitude <= 90.0) && + (longitude >= -180 && longitude <= 180) + } + + val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() } + + // @return distance in meters to some other node (or null if unknown) + fun distance(o: NodeEntity): Int? = when { + validPosition == null || o.validPosition == null -> null + else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt() + } + + // @return a nice human readable string for the distance, or null for unknown + fun distanceStr(o: NodeEntity, displayUnits: Int = 0): String? = distance(o)?.let { dist -> + val system = DisplayConfig.DisplayUnits.forNumber(displayUnits) + return if (dist > 0) dist.toDistanceString(system) else null + } + + // @return bearing to the other position in degrees + fun bearing(o: NodeEntity?): Int? = when { + validPosition == null || o?.validPosition == null -> null + else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt() + } + + fun gpsString(gpsFormat: Int): String = when (gpsFormat) { + DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude) + else -> GPSFormat.toDEC(latitude, longitude) + } + + private fun TelemetryProtos.EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String { + val temp = if (temperature != 0f) { + if (isFahrenheit) { + val fahrenheit = temperature * 1.8F + 32 + "%.1f°F".format(fahrenheit) + } else { + "%.1f°C".format(temperature) + } + } else { + null + } + val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null + val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null + val current = if (current != 0f) "%.1fmA".format(current) else null + val iaq = if (iaq != 0) "IAQ: $iaq" else null + + return listOfNotNull( + temp, + humidity, + voltage, + current, + iaq, + ).joinToString(" ") + } + + private fun PaxcountProtos.Paxcount.getDisplayString() = + "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 && wifi != 0 } + + fun getTelemetryString(isFahrenheit: Boolean = false): String { + return listOfNotNull( + paxcounter.getDisplayString(), + environmentMetrics.getDisplayString(isFahrenheit), + ).joinToString(" ") + } + + /** + * true if the device was heard from recently + */ + val isOnline: Boolean + get() { + val now = System.currentTimeMillis() / 1000 + val timeout = 2 * 60 * 60 + return (now - lastHeard <= timeout) + } + + companion object { + // Convert to a double representation of degrees + fun degD(i: Int) = i * 1e-7 + fun degI(d: Double) = (d * 1e7).toInt() + + fun currentTime() = (System.currentTimeMillis() / 1000).toInt() + } +} + +fun NodeEntity.toNodeInfo() = NodeInfo( + num = num, + user = MeshUser( + id = user.id, + longName = user.longName, + shortName = user.shortName, + hwModel = user.hwModel, + role = user.roleValue, + ).takeIf { user.id.isNotEmpty() }, + position = Position( + latitude = latitude, + longitude = longitude, + altitude = position.altitude, + time = position.time, + satellitesInView = position.satsInView, + groundSpeed = position.groundSpeed, + groundTrack = position.groundTrack, + precisionBits = position.precisionBits, + ).takeIf { it.isValid() }, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = DeviceMetrics( + time = deviceTelemetry.time, + batteryLevel = deviceMetrics.batteryLevel, + voltage = deviceMetrics.voltage, + channelUtilization = deviceMetrics.channelUtilization, + airUtilTx = deviceMetrics.airUtilTx, + uptimeSeconds = deviceMetrics.uptimeSeconds, + ), + channel = channel, + environmentMetrics = EnvironmentMetrics( + time = environmentTelemetry.time, + temperature = environmentMetrics.temperature, + relativeHumidity = environmentMetrics.relativeHumidity, + barometricPressure = environmentMetrics.barometricPressure, + gasResistance = environmentMetrics.gasResistance, + voltage = environmentMetrics.voltage, + current = environmentMetrics.current, + iaq = environmentMetrics.iaq, + ), + hopsAway = hopsAway, +) diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt new file mode 100644 index 000000000..3952519f1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -0,0 +1,36 @@ +package com.geeksville.mesh.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.geeksville.mesh.DataPacket + +@Entity( + tableName = "packet", + indices = [ + Index(value = ["myNodeNum"]), + Index(value = ["port_num"]), + Index(value = ["contact_key"]), + ] +) + +data class Packet( + @PrimaryKey(autoGenerate = true) val uuid: Long, + @ColumnInfo(name = "myNodeNum", defaultValue = "0") val myNodeNum: Int, + @ColumnInfo(name = "port_num") val port_num: Int, + @ColumnInfo(name = "contact_key") val contact_key: String, + @ColumnInfo(name = "received_time") val received_time: Long, + @ColumnInfo(name = "read", defaultValue = "1") val read: Boolean, + @ColumnInfo(name = "data") val data: DataPacket, + @ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0, + @ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1, +) + +@Entity(tableName = "contact_settings") +data class ContactSettings( + @PrimaryKey val contact_key: String, + val muteUntil: Long = 0L, +) { + val isMuted get() = System.currentTimeMillis() <= muteUntil +} diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt b/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt new file mode 100644 index 000000000..5f88ae770 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt @@ -0,0 +1,19 @@ +package com.geeksville.mesh.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "quick_chat") +data class QuickChatAction( + @PrimaryKey(autoGenerate = true) val uuid: Long, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "message") val message: String, + @ColumnInfo(name = "mode") val mode: Mode, + @ColumnInfo(name = "position") val position: Int +) { + enum class Mode { + Append, + Instant, + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt new file mode 100644 index 000000000..d6b67b9e0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -0,0 +1,270 @@ +package com.geeksville.mesh.model + +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.hardware.usb.UsbManager +import android.net.nsd.NsdServiceInfo +import android.os.RemoteException +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.R +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.repository.network.NetworkRepository +import com.geeksville.mesh.repository.radio.InterfaceId +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.repository.usb.UsbRepository +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.service.ServiceRepository +import com.geeksville.mesh.util.anonymize +import com.hoho.android.usbserial.driver.UsbSerialDriver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +class BTScanModel @Inject constructor( + private val application: Application, + private val serviceRepository: ServiceRepository, + private val bluetoothRepository: BluetoothRepository, + private val usbRepository: UsbRepository, + private val usbManagerLazy: dagger.Lazy, + private val networkRepository: NetworkRepository, + private val radioInterfaceService: RadioInterfaceService, +) : ViewModel(), Logging { + + private val context: Context get() = application.applicationContext + val devices = MutableLiveData>(mutableMapOf()) + val errorText = MutableLiveData(null) + + private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface) + + fun showMockInterface() { + showMockInterface.value = true + } + + init { + combine( + bluetoothRepository.state, + networkRepository.resolvedList, + usbRepository.serialDevicesWithDrivers, + showMockInterface, + ) { ble, tcp, usb, showMockInterface -> + devices.value = mutableMapOf().apply { + fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry } + + // Include a placeholder for "None" + addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + + if (showMockInterface) { + addDevice(DeviceListEntry("Demo Mode", "m", true)) + } + + // Include paired Bluetooth devices + ble.bondedDevices.forEach { + addDevice(BLEDeviceListEntry(it)) + } + + // Include Network Service Discovery + tcp.forEach { service -> + addDevice(TCPDeviceListEntry(service)) + } + + usb.forEach { (_, d) -> + addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) + } + } + }.launchIn(viewModelScope) + + serviceRepository.statusMessage + .onEach { errorText.value = it } + .launchIn(viewModelScope) + + debug("BTScanModel created") + } + + /** + * @param fullAddress Interface [prefix] + [address] (example: "x7C:9E:BD:F0:BE:BE") + */ + open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) { + val prefix get() = fullAddress[0] + val address get() = fullAddress.substring(1) + + override fun toString(): String { + return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" + } + + val isBLE: Boolean get() = prefix == 'x' + val isUSB: Boolean get() = prefix == 's' + val isTCP: Boolean get() = prefix == 't' + } + + @SuppressLint("MissingPermission") + class BLEDeviceListEntry(device: BluetoothDevice) : DeviceListEntry( + device.name ?: "unnamed-${device.address}", // some devices might not have a name + "x${device.address}", + device.bondState == BluetoothDevice.BOND_BONDED + ) + + class USBDeviceListEntry( + radioInterfaceService: RadioInterfaceService, + usbManager: UsbManager, + val usb: UsbSerialDriver, + ) : DeviceListEntry( + usb.device.deviceName, + radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, usb.device.deviceName), + usbManager.hasPermission(usb.device), + ) + + class TCPDeviceListEntry(val service: NsdServiceInfo) : DeviceListEntry( + service.host.toString().substring(1), + service.host.toString().replace("/", "t"), + true + ) + + override fun onCleared() { + super.onCleared() + debug("BTScanModel cleared") + } + + fun setErrorText(text: String) { + errorText.value = text + } + + private var scanJob: Job? = null + + val selectedAddress get() = radioInterfaceService.getDeviceAddress() + val selectedBluetooth: Boolean get() = selectedAddress?.getOrNull(0) == 'x' + + /// Use the string for the NopInterface + val selectedNotNull: String get() = selectedAddress ?: "n" + + val scanResult = MutableLiveData>(mutableMapOf()) + + fun clearScanResults() { + stopScan() + scanResult.value = mutableMapOf() + } + + fun stopScan() { + if (scanJob != null) { + debug("stopping scan") + try { + scanJob?.cancel() + } catch (ex: Throwable) { + warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") + } finally { + scanJob = null + } + } + _spinner.value = false + } + + @SuppressLint("MissingPermission") + fun startScan() { + debug("starting classic scan") + + _spinner.value = true + scanJob = bluetoothRepository.scan() + .onEach { result -> + val fullAddress = radioInterfaceService.toInterfaceAddress( + InterfaceId.BLUETOOTH, + result.device.address + ) + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = scanResult.value!! + val oldEntry = oldDevs[fullAddress] + // Don't spam the GUI with endless updates for non changing nodes + if (oldEntry == null || oldEntry.bonded != isBonded) { + val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + oldDevs[entry.fullAddress] = entry + scanResult.value = oldDevs + } + }.catch { ex -> + serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") + }.launchIn(viewModelScope) + } + + private fun changeDeviceAddress(address: String) { + try { + serviceRepository.meshService?.let { service -> + MeshService.changeDeviceAddress(context, service, address) + } + devices.value = devices.value // Force a GUI update + } catch (ex: RemoteException) { + errormsg("changeDeviceSelection failed, probably it is shutting down", ex) + // ignore the failure and the GUI won't be updating anyways + } + } + + @SuppressLint("MissingPermission") + private fun requestBonding(it: DeviceListEntry) { + val device = bluetoothRepository.getRemoteDevice(it.address) ?: return + info("Starting bonding for ${device.anonymize}") + + bluetoothRepository.createBond(device) + .onEach { state -> + debug("Received bond state changed $state") + if (state != BluetoothDevice.BOND_BONDING) { + debug("Bonding completed, state=$state") + if (state == BluetoothDevice.BOND_BONDED) { + setErrorText(context.getString(R.string.pairing_completed)) + changeDeviceAddress(it.fullAddress) + } else { + setErrorText(context.getString(R.string.pairing_failed_try_again)) + } + } + }.catch { ex -> + // We ignore missing BT adapters, because it lets us run on the emulator + warn("Failed creating Bluetooth bond: ${ex.message}") + }.launchIn(viewModelScope) + } + + private fun requestPermission(it: USBDeviceListEntry) { + usbRepository.requestPermission(it.usb.device) + .onEach { granted -> + if (granted) { + info("User approved USB access") + changeDeviceAddress(it.fullAddress) + } else { + errormsg("USB permission denied for device ${it.address}") + } + }.launchIn(viewModelScope) + } + + // Called by the GUI when a new device has been selected by the user + // @returns true if we were able to change to that item + fun onSelected(it: DeviceListEntry): Boolean { + // If the device is paired, let user select it, otherwise start the pairing flow + if (it.bonded) { + changeDeviceAddress(it.fullAddress) + return true + } else { + // Handle requesting USB or bluetooth permissions for the device + debug("Requesting permissions for the device") + + if (it.isBLE) { + requestBonding(it) + } + + if (it.isUSB) { + requestPermission(it as USBDeviceListEntry) + } + + return false + } + } + + private val _spinner = MutableLiveData(false) + val spinner: LiveData get() = _spinner +} diff --git a/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt new file mode 100644 index 000000000..6562f41be --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/BluetoothViewModel.kt @@ -0,0 +1,24 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Thin view model which adapts the view layer to the `BluetoothRepository`. + */ +@HiltViewModel +class BluetoothViewModel @Inject constructor( + private val bluetoothRepository: BluetoothRepository, +) : ViewModel() { + /** + * Called when permissions have been updated. This causes an explicit refresh of the + * bluetooth state. + */ + fun permissionsUpdated() = bluetoothRepository.refreshState() + + val enabled = bluetoothRepository.state.map { it.enabled }.asLiveData() +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/Channel.kt b/app/src/main/java/com/geeksville/mesh/model/Channel.kt new file mode 100644 index 000000000..71407471c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/Channel.kt @@ -0,0 +1,103 @@ +package com.geeksville.mesh.model + +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig.ModemPreset +import com.geeksville.mesh.ConfigKt.loRaConfig +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.channelSettings +import com.google.protobuf.ByteString +import java.security.SecureRandom + +/** Utility function to make it easy to declare byte arrays - FIXME move someplace better */ +fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() } +fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and 0xff) } + +data class Channel( + val settings: ChannelProtos.ChannelSettings = default.settings, + val loraConfig: ConfigProtos.Config.LoRaConfig = default.loraConfig, +) { + companion object { + // These bytes must match the well known and not secret bytes used the default channel AES128 key device code + private val channelDefaultKey = byteArrayOfInts( + 0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, + 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01 + ) + + private val cleartextPSK = ByteString.EMPTY + private val defaultPSK = + byteArrayOfInts(1) // a shortstring code to indicate we need our default PSK + + // The default channel that devices ship with + val default = Channel( + channelSettings { psk = ByteString.copyFrom(defaultPSK) }, + // references: NodeDB::installDefaultConfig / Channels::initDefaultChannel + loRaConfig { + usePreset = true + modemPreset = ModemPreset.LONG_FAST + hopLimit = 3 + txEnabled = true + } + ) + + fun getRandomKey(size: Int = 32): ByteString { + val bytes = ByteArray(size) + val random = SecureRandom() + random.nextBytes(bytes) + return ByteString.copyFrom(bytes) + } + } + + // Return the name of our channel as a human readable string. If empty string, assume "Default" per mesh.proto spec + val name: String + get() = settings.name.ifEmpty { + // We have a new style 'empty' channel name. Use the same logic from the device to convert that to a human readable name + if (loraConfig.usePreset) when (loraConfig.modemPreset) { + ModemPreset.SHORT_TURBO -> "ShortTurbo" + ModemPreset.SHORT_FAST -> "ShortFast" + ModemPreset.SHORT_SLOW -> "ShortSlow" + ModemPreset.MEDIUM_FAST -> "MediumFast" + ModemPreset.MEDIUM_SLOW -> "MediumSlow" + ModemPreset.LONG_FAST -> "LongFast" + ModemPreset.LONG_SLOW -> "LongSlow" + ModemPreset.LONG_MODERATE -> "LongMod" + ModemPreset.VERY_LONG_SLOW -> "VLongSlow" + else -> "Invalid" + } else "Custom" + } + + val psk: ByteString + get() = if (settings.psk.size() != 1) { + settings.psk // A standard PSK + } else { + // One of our special 1 byte PSKs, see mesh.proto for docs. + val pskIndex = settings.psk.byteAt(0).toInt() + + if (pskIndex == 0) { + cleartextPSK + } else { + // Treat an index of 1 as the old channelDefaultKey and work up from there + val bytes = channelDefaultKey.clone() + bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte() + ByteString.copyFrom(bytes) + } + } + + /** + * Given a channel name and psk, return the (0 to 255) hash for that channel + */ + val hash: Int get() = xorHash(name.toByteArray()) xor xorHash(psk.toByteArray()) + + val channelNum: Int get() = loraConfig.channelNum(name) + + val radioFreq: Float get() = loraConfig.radioFreq(channelNum) + + override fun equals(other: Any?): Boolean = (other is Channel) + && psk.toByteArray() contentEquals other.psk.toByteArray() + && name == other.name + + override fun hashCode(): Int { + var result = settings.hashCode() + result = 31 * result + loraConfig.hashCode() + return result + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt new file mode 100644 index 000000000..3f4418f0b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelOption.kt @@ -0,0 +1,111 @@ +package com.geeksville.mesh.model + +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig.ModemPreset +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig.RegionCode +import com.geeksville.mesh.R + +/** + * hash a string into an integer using the djb2 algorithm by Dan Bernstein + * http://www.cse.yorku.ca/~oz/hash.html + */ +private fun hash(name: String): UInt { // using UInt instead of Long to match RadioInterface.cpp results + var hash: UInt = 5381u + for (c in name) { + hash += (hash shl 5) + c.code.toUInt() + } + return hash +} + +private val ModemPreset.bandwidth: Float + get() { + for (option in ChannelOption.entries) { + if (option.modemPreset == this) return option.bandwidth + } + return 0f + } + +private fun LoRaConfig.bandwidth() = if (usePreset) { + val wideLora = region == RegionCode.LORA_24 + modemPreset.bandwidth * if (wideLora) 3.25f else 1f +} else when (bandwidth) { + 31 -> .03125f + 62 -> .0625f + 200 -> .203125f + 400 -> .40625f + 800 -> .8125f + 1600 -> 1.6250f + else -> bandwidth / 1000f +} + +val LoRaConfig.numChannels: Int get() { + for (option in RegionInfo.entries) { + if (option.regionCode == region) { + return ((option.freqEnd - option.freqStart) / bandwidth()).toInt() + } + } + return 0 +} + +internal fun LoRaConfig.channelNum(primaryName: String): Int = when { + channelNum != 0 -> channelNum + numChannels == 0 -> 0 + else -> (hash(primaryName) % numChannels.toUInt()).toInt() + 1 +} + +internal fun LoRaConfig.radioFreq(channelNum: Int): Float { + if (overrideFrequency != 0f) return overrideFrequency + frequencyOffset + for (option in RegionInfo.entries) { + if (option.regionCode == region) { + return (option.freqStart + bandwidth() / 2) + (channelNum - 1) * bandwidth() + } + } + return 0f +} + +@Suppress("MagicNumber") +enum class RegionInfo( + val regionCode: RegionCode, + val description: String, + val freqStart: Float, + val freqEnd: Float, +) { + UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f), + US(RegionCode.US, "United States", 902.0f, 928.0f), + EU_433(RegionCode.EU_433, "European Union 433MHz", 433.0f, 434.0f), + EU_868(RegionCode.EU_868, "European Union 868MHz", 869.4f, 869.65f), + CN(RegionCode.CN, "China", 470.0f, 510.0f), + JP(RegionCode.JP, "Japan", 920.5f, 923.5f), + ANZ(RegionCode.ANZ, "Australia / New Zealand", 915.0f, 928.0f), + KR(RegionCode.KR, "Korea", 920.0f, 923.0f), + TW(RegionCode.TW, "Taiwan", 920.0f, 925.0f), + RU(RegionCode.RU, "Russia", 868.7f, 869.2f), + IN(RegionCode.IN, "India", 865.0f, 867.0f), + NZ_865(RegionCode.NZ_865, "New Zealand 865MHz", 864.0f, 868.0f), + TH(RegionCode.TH, "Thailand", 920.0f, 925.0f), + UA_433(RegionCode.UA_433, "Ukraine 433MHz", 433.0f, 434.7f), + UA_868(RegionCode.UA_868, "Ukraine 868MHz", 868.0f, 868.6f), + MY_433(RegionCode.MY_433, "Malaysia 433MHz", 433.0f, 435.0f), + MY_919(RegionCode.MY_919, "Malaysia 919MHz", 919.0f, 924.0f), + SG_923(RegionCode.SG_923, "Singapore 923MHz", 917.0f, 925.0f), + PH_433(RegionCode.PH_433, "Philippines 433MHz", 433.0f, 434.7f), + PH_868(RegionCode.PH_868, "Philippines 868MHz", 868.0f, 869.4f), + PH_915(RegionCode.PH_915, "Philippines 915MHz", 915.0f, 918.0f), + LORA_24(RegionCode.LORA_24, "2.4 GHz", 2400.0f, 2483.5f), +} + +enum class ChannelOption( + val modemPreset: ModemPreset, + val configRes: Int, + val bandwidth: Float, +) { + SHORT_TURBO(ModemPreset.SHORT_TURBO, R.string.modem_config_turbo, bandwidth = .500f), + SHORT_FAST(ModemPreset.SHORT_FAST, R.string.modem_config_short, .250f), + SHORT_SLOW(ModemPreset.SHORT_SLOW, R.string.modem_config_slow_short, .250f), + MEDIUM_FAST(ModemPreset.MEDIUM_FAST, R.string.modem_config_medium, .250f), + MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, R.string.modem_config_slow_medium, .250f), + LONG_FAST(ModemPreset.LONG_FAST, R.string.modem_config_long, .250f), + LONG_MODERATE(ModemPreset.LONG_MODERATE, R.string.modem_config_mod_long, .125f), + LONG_SLOW(ModemPreset.LONG_SLOW, R.string.modem_config_slow_long, .125f), + VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, R.string.modem_config_very_long, .0625f), +} diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt new file mode 100644 index 000000000..e10d615ec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -0,0 +1,100 @@ +package com.geeksville.mesh.model + +import android.graphics.Bitmap +import android.net.Uri +import android.util.Base64 +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.android.BuildUtils.errormsg +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.journeyapps.barcodescanner.BarcodeEncoder +import java.net.MalformedURLException +import kotlin.jvm.Throws + +internal const val URL_PREFIX = "https://meshtastic.org/e/#" +private const val MESHTASTIC_DOMAIN = "meshtastic.org" +private const val MESHTASTIC_CHANNEL_CONFIG_PATH = "/e/" +private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING + +/** + * Return a [ChannelSet] that represents the ChannelSet encoded by the URL. + * @throws MalformedURLException when not recognized as a valid Meshtastic URL + */ +@Throws(MalformedURLException::class) +fun Uri.toChannelSet(): ChannelSet { + if (fragment.isNullOrBlank() || + !host.equals(MESHTASTIC_DOMAIN, true) || + !path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true) + ) { + throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") + } + + // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. + // This gracefully handles those cases until the newer version are generally available/used. + return ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)) +} + +/** + * Return a [Boolean] if the URL indicates the associated [ChannelSet] should be added to the + * existing configuration. + * @throws MalformedURLException when not recognized as a valid Meshtastic URL + */ +@Throws(MalformedURLException::class) +fun Uri.shouldAddChannels(): Boolean { + if (fragment.isNullOrBlank() || + !host.equals(MESHTASTIC_DOMAIN, true) || + !path.equals(MESHTASTIC_CHANNEL_CONFIG_PATH, true) + ) { + throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}") + } + + // Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment. + // This gracefully handles those cases until the newer version are generally available/used. + return fragment?.substringAfter('?', "") + ?.takeUnless { it.isBlank() } + ?.equals("add=true") + ?: getBooleanQueryParameter("add", false) +} + +/** + * @return A list of globally unique channel IDs usable with MQTT subscribe() + */ +val ChannelSet.subscribeList: List + get() = settingsList.filter { it.downlinkEnabled }.map { Channel(it, loraConfig).name } + +fun ChannelSet.getChannel(index: Int): Channel? = + if (settingsCount > index) Channel(getSettings(index), loraConfig) else null + +/** + * Return the primary channel info + */ +val ChannelSet.primaryChannel: Channel? get() = getChannel(0) + +/** + * Return a URL that represents the [ChannelSet] + * @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes + */ +fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false): Uri { + val channelBytes = this.toByteArray() ?: ByteArray(0) // if unset just use empty + val enc = Base64.encodeToString(channelBytes, BASE64FLAGS) + val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX + return Uri.parse("$p$enc") +} + +val ChannelSet.qrCode: Bitmap? + get() = try { + val multiFormatWriter = MultiFormatWriter() + + val bitMatrix = + multiFormatWriter.encode( + getChannelUrl(false).toString(), + BarcodeFormat.QR_CODE, + 960, + 960 + ) + val barcodeEncoder = BarcodeEncoder() + barcodeEncoder.createBitmap(bitMatrix) + } catch (ex: Throwable) { + errormsg("URL was too complex to render as barcode") + null + } diff --git a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt new file mode 100644 index 000000000..72d5195ea --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt @@ -0,0 +1,39 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.entity.MeshLog +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DebugViewModel @Inject constructor( + private val meshLogRepository: MeshLogRepository, +) : ViewModel(), Logging { + + private val _meshLog = MutableStateFlow>(emptyList()) + val meshLog: StateFlow> = _meshLog + + init { + viewModelScope.launch { + meshLogRepository.getAllLogs().collect { _meshLog.value = it } + } + + debug("DebugViewModel created") + } + + override fun onCleared() { + super.onCleared() + debug("DebugViewModel cleared") + } + + fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { + meshLogRepository.deleteAll() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt new file mode 100644 index 000000000..09ba6fcf8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceVersion.kt @@ -0,0 +1,34 @@ +package com.geeksville.mesh.model + +import com.geeksville.mesh.android.Logging + +/** + * Provide structured access to parse and compare device version strings + */ +data class DeviceVersion(val asString: String) : Comparable, Logging { + + val asInt + get() = try { + verStringToInt(asString) + } catch (e: Exception) { + warn("Exception while parsing version '$asString', assuming version 0") + 0 + } + + /** + * Convert a version string of the form 1.23.57 to a comparable integer of + * the form 12357. + * + * Or throw an exception if the string can not be parsed + */ + private fun verStringToInt(s: String): Int { + // Allow 1 to two digits per match + val match = + Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(s) + ?: throw Exception("Can't parse version $s") + val (major, minor, build) = match.destructured + return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt() + } + + override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/Message.kt b/app/src/main/java/com/geeksville/mesh/model/Message.kt new file mode 100644 index 000000000..934cf611d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/Message.kt @@ -0,0 +1,54 @@ +package com.geeksville.mesh.model + +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshProtos.Routing +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.R + +val Routing.Error.stringRes: Int + get() = when (this) { + Routing.Error.NONE -> R.string.routing_error_none + Routing.Error.NO_ROUTE -> R.string.routing_error_no_route + Routing.Error.GOT_NAK -> R.string.routing_error_got_nak + Routing.Error.TIMEOUT -> R.string.routing_error_timeout + Routing.Error.NO_INTERFACE -> R.string.routing_error_no_interface + Routing.Error.MAX_RETRANSMIT -> R.string.routing_error_max_retransmit + Routing.Error.NO_CHANNEL -> R.string.routing_error_no_channel + Routing.Error.TOO_LARGE -> R.string.routing_error_too_large + Routing.Error.NO_RESPONSE -> R.string.routing_error_no_response + Routing.Error.DUTY_CYCLE_LIMIT -> R.string.routing_error_duty_cycle_limit + Routing.Error.BAD_REQUEST -> R.string.routing_error_bad_request + Routing.Error.NOT_AUTHORIZED -> R.string.routing_error_not_authorized + Routing.Error.PKI_FAILED -> R.string.routing_error_pki_failed + Routing.Error.PKI_UNKNOWN_PUBKEY -> R.string.routing_error_pki_unknown_pubkey + Routing.Error.ADMIN_BAD_SESSION_KEY -> R.string.routing_error_admin_bad_session_key + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED -> R.string.routing_error_admin_public_key_unauthorized + else -> R.string.unrecognized + } + +data class Message( + val uuid: Long, + val receivedTime: Long, + val user: MeshProtos.User, + val text: String, + val time: String, + val read: Boolean, + val status: MessageStatus?, + val routingError: Int, +) { + private fun getStatusStringRes(value: Int): Int { + val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED + return error.stringRes + } + + fun getStatusStringRes(): Pair { + val title = if (routingError > 0) R.string.error else R.string.message_delivery_status + val text = when (status) { + MessageStatus.RECEIVED -> R.string.delivery_confirmed + MessageStatus.QUEUED -> R.string.message_status_queued + MessageStatus.ENROUTE -> R.string.message_status_enroute + else -> getStatusStringRes(routingError) + } + return title to text + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt new file mode 100644 index 000000000..3dceafae1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -0,0 +1,205 @@ +package com.geeksville.mesh.model + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.MeshProtos.Position +import com.geeksville.mesh.Portnums.PortNum +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedWriter +import java.io.FileNotFoundException +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject + +data class MetricsState( + val isManaged: Boolean = true, + val isFahrenheit: Boolean = false, + val displayUnits: DisplayUnits = DisplayUnits.METRIC, + val deviceMetrics: List = emptyList(), + val environmentMetrics: List = emptyList(), + val signalMetrics: List = emptyList(), + val tracerouteRequests: List = emptyList(), + val tracerouteResults: List = emptyList(), + val positionLogs: List = emptyList(), +) { + fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() + fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() + fun hasSignalMetrics() = signalMetrics.isNotEmpty() + fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() + fun hasPositionLogs() = positionLogs.isNotEmpty() + + companion object { + val Empty = MetricsState() + } +} + +private fun MeshPacket.hasValidSignal(): Boolean = + rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0) + +private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) { + runCatching { Position.parseFrom(decoded.payload) }.getOrNull() +} else { + null +} + +@HiltViewModel +class MetricsViewModel @Inject constructor( + private val app: Application, + private val dispatchers: CoroutineDispatchers, + private val meshLogRepository: MeshLogRepository, + private val radioConfigRepository: RadioConfigRepository, +) : ViewModel(), Logging { + private val destNum = MutableStateFlow(0) + + private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { + hasDecoded() && decoded.wantResponse && from == 0 && to == destNum.value + } + + fun getUser(nodeNum: Int) = radioConfigRepository.getUser(nodeNum) + + fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { + meshLogRepository.deleteLog(uuid) + } + + fun clearPosition() = viewModelScope.launch(dispatchers.io) { + meshLogRepository.deleteLogs(destNum.value, PortNum.POSITION_APP_VALUE) + } + + private val _state = MutableStateFlow(MetricsState.Empty) + val state: StateFlow = _state + + init { + radioConfigRepository.deviceProfileFlow.onEach { profile -> + val moduleConfig = profile.moduleConfig + _state.update { state -> + state.copy( + isManaged = profile.config.security.isManaged, + isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit, + ) + } + }.launchIn(viewModelScope) + + @OptIn(ExperimentalCoroutinesApi::class) + destNum.flatMapLatest { destNum -> + meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry -> + _state.update { state -> + state.copy( + deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, + environmentMetrics = telemetry.filter { + it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f + }, + ) + } + } + }.launchIn(viewModelScope) + + @OptIn(ExperimentalCoroutinesApi::class) + destNum.flatMapLatest { destNum -> + meshLogRepository.getMeshPacketsFrom(destNum).onEach { meshPackets -> + _state.update { state -> + state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + } + } + }.launchIn(viewModelScope) + + @OptIn(ExperimentalCoroutinesApi::class) + destNum.flatMapLatest { destNum -> + combine( + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE), + meshLogRepository.getMeshPacketsFrom(destNum, PortNum.TRACEROUTE_APP_VALUE), + ) { request, response -> + _state.update { state -> + state.copy( + tracerouteRequests = request.filter { it.hasValidTraceroute() }, + tracerouteResults = response, + ) + } + } + }.launchIn(viewModelScope) + + @OptIn(ExperimentalCoroutinesApi::class) + destNum.flatMapLatest { destNum -> + meshLogRepository.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE).onEach { packets -> + _state.update { state -> + state.copy(positionLogs = packets.mapNotNull { it.toPosition() }) + } + } + }.launchIn(viewModelScope) + + debug("MetricsViewModel created") + } + + override fun onCleared() { + super.onCleared() + debug("MetricsViewModel cleared") + } + + /** + * Used to set the Node for which the user will see charts for. + */ + fun setSelectedNode(nodeNum: Int) { + destNum.value = nodeNum + } + + /** + * Write the persisted Position data out to a CSV file in the specified location. + */ + fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) { + val positions = state.value.positionLogs + writeToUri(uri) { writer -> + writer.appendLine("\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"") + + val dateFormat = + SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault()) + + positions.forEach { position -> + val rxDateTime = dateFormat.format(position.time * 1000L) + val latitude = position.latitudeI * 1e-7 + val longitude = position.longitudeI * 1e-7 + val altitude = position.altitude + val satsInView = position.satsInView + val speed = position.groundSpeed + val heading = "%.2f".format(position.groundTrack * 1e-5) + + // date,time,latitude,longitude,altitude,satsInView,speed,heading + writer.appendLine("$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"") + } + } + } + + private suspend inline fun writeToUri( + uri: Uri, + crossinline block: suspend (BufferedWriter) -> Unit + ) = withContext(dispatchers.io) { + try { + app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> + FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter -> + BufferedWriter(fileWriter).use { writer -> block.invoke(writer) } + } + } + } catch (ex: FileNotFoundException) { + errormsg("Can't write file error: ${ex.message}") + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt new file mode 100644 index 000000000..900a79063 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -0,0 +1,87 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NodeDB @Inject constructor( + processLifecycle: Lifecycle, + private val nodeInfoDao: NodeInfoDao, +) { + // hardware info about our local device (can be null) + private val _myNodeInfo = MutableStateFlow(null) + val myNodeInfo: StateFlow get() = _myNodeInfo + + // our node info + private val _ourNodeInfo = MutableStateFlow(null) + val ourNodeInfo: StateFlow get() = _ourNodeInfo + + // The unique userId of our node + private val _myId = MutableStateFlow(null) + val myId: StateFlow get() = _myId + + // A map from nodeNum to NodeEntity + private val _nodeDBbyNum = MutableStateFlow>(mapOf()) + val nodeDBbyNum: StateFlow> get() = _nodeDBbyNum + + fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) + + fun getUser(userId: String): MeshProtos.User = + nodeDBbyNum.value.values.find { it.user.id == userId }?.user + ?: MeshProtos.User.newBuilder() + .setId(userId) + .setLongName("Meshtastic ${userId.takeLast(n = 4)}") + .setShortName(userId.takeLast(n = 4)) + .setHwModel(MeshProtos.HardwareModel.UNSET) + .build() + + init { + nodeInfoDao.getMyNodeInfo().onEach { _myNodeInfo.value = it } + .launchIn(processLifecycle.coroutineScope) + + nodeInfoDao.nodeDBbyNum().onEach { + _nodeDBbyNum.value = it + val ourNodeInfo = it.values.firstOrNull() + _ourNodeInfo.value = ourNodeInfo + _myId.value = ourNodeInfo?.user?.id + }.launchIn(processLifecycle.coroutineScope) + } + + fun getNodes( + sort: NodeSortOption = NodeSortOption.LAST_HEARD, + filter: String = "", + includeUnknown: Boolean = true, + ) = nodeInfoDao.getNodes( + sort = sort.sqlValue, + filter = filter, + includeUnknown = includeUnknown, + ) + + suspend fun upsert(node: NodeEntity) = withContext(Dispatchers.IO) { + nodeInfoDao.upsert(node) + } + + suspend fun installNodeDB(mi: MyNodeEntity, nodes: List) = withContext(Dispatchers.IO) { + nodeInfoDao.clearMyNodeInfo() + nodeInfoDao.setMyNodeInfo(mi) // set MyNodeEntity first + nodeInfoDao.clearNodeInfo() + nodeInfoDao.putAll(nodes) + } + + suspend fun deleteNode(num: Int) = withContext(Dispatchers.IO) { + nodeInfoDao.deleteNode(num) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt new file mode 100644 index 000000000..90e57bed7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt @@ -0,0 +1,591 @@ +package com.geeksville.mesh.model + +import android.app.Application +import android.net.Uri +import android.os.RemoteException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.AdminProtos +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.IMeshService +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.Portnums +import com.geeksville.mesh.Position +import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.config +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.deviceProfile +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.service.MeshService.ConnectionState +import com.geeksville.mesh.ui.AdminRoute +import com.geeksville.mesh.ui.ConfigRoute +import com.geeksville.mesh.ui.ModuleRoute +import com.geeksville.mesh.ui.ResponseState +import com.google.protobuf.MessageLite +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileOutputStream +import javax.inject.Inject + +/** + * Data class that represents the current RadioConfig state. + */ +data class RadioConfigState( + val connected: Boolean = false, + val route: String = "", + val metadata: MeshProtos.DeviceMetadata = MeshProtos.DeviceMetadata.getDefaultInstance(), + val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + val channelList: List = emptyList(), + val radioConfig: ConfigProtos.Config = config {}, + val moduleConfig: ModuleConfigProtos.ModuleConfig = moduleConfig {}, + val ringtone: String = "", + val cannedMessageMessages: String = "", + val responseState: ResponseState = ResponseState.Empty, +) { + fun hasMetadata() = metadata != MeshProtos.DeviceMetadata.getDefaultInstance() +} + +@HiltViewModel +class RadioConfigViewModel @Inject constructor( + private val app: Application, + private val radioConfigRepository: RadioConfigRepository, +) : ViewModel(), Logging { + + private val meshService: IMeshService? get() = radioConfigRepository.meshService + + private val _destNum = MutableStateFlow(null) + private val _destNode = MutableStateFlow(null) + val destNode: StateFlow get() = _destNode + + /** + * Sets the destination [NodeEntity] used in Radio Configuration. + * @param num Destination nodeNum (or null for our local [NodeEntity]). + */ + fun setDestNum(num: Int?) { + _destNum.value = num + } + + private val requestIds = MutableStateFlow(hashSetOf()) + private val _radioConfigState = MutableStateFlow(RadioConfigState()) + val radioConfigState: StateFlow = _radioConfigState + + private val _currentDeviceProfile = MutableStateFlow(deviceProfile {}) + val currentDeviceProfile get() = _currentDeviceProfile.value + + init { + combine(_destNum, radioConfigRepository.nodeDBbyNum) { destNum, nodes -> + nodes[destNum] ?: nodes.values.firstOrNull() + }.onEach { _destNode.value = it }.launchIn(viewModelScope) + + radioConfigRepository.deviceProfileFlow.onEach { + _currentDeviceProfile.value = it + }.launchIn(viewModelScope) + + radioConfigRepository.meshPacketFlow.onEach(::processPacketResponse) + .launchIn(viewModelScope) + + combine(radioConfigRepository.connectionState, radioConfigState) { connState, configState -> + _radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) } + if (connState.isDisconnected() && configState.responseState.isWaiting()) { + setResponseStateError(app.getString(R.string.disconnected)) + } + }.launchIn(viewModelScope) + + debug("RadioConfigViewModel created") + } + + private val myNodeInfo: StateFlow get() = radioConfigRepository.myNodeInfo + val myNodeNum get() = myNodeInfo.value?.myNodeNum + val maxChannels get() = myNodeInfo.value?.maxChannels ?: 8 + + val hasPaFan: Boolean + get() = destNode.value?.user?.hwModel in setOf( + null, + MeshProtos.HardwareModel.UNRECOGNIZED, + MeshProtos.HardwareModel.UNSET, + MeshProtos.HardwareModel.BETAFPV_2400_TX, + MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT_NANO, + MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT, + ) + + override fun onCleared() { + super.onCleared() + debug("RadioConfigViewModel cleared") + } + + private fun request( + destNum: Int, + requestAction: suspend (IMeshService, Int, Int) -> Unit, + errorMessage: String, + ) = viewModelScope.launch { + meshService?.let { service -> + val packetId = service.packetId + try { + requestAction(service, packetId, destNum) + requestIds.update { it.apply { add(packetId) } } + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + val total = maxOf(requestIds.value.size, state.responseState.total) + state.copy(responseState = state.responseState.copy(total = total)) + } else { + state.copy( + route = "", // setter (response is PortNum.ROUTING_APP) + responseState = ResponseState.Loading(), + ) + } + } + } catch (ex: RemoteException) { + errormsg("$errorMessage: ${ex.message}") + } + } + } + + fun setOwner(user: MeshProtos.User) { + setRemoteOwner(destNode.value?.num ?: return, user) + } + + private fun setRemoteOwner(destNum: Int, user: MeshProtos.User) = request( + destNum, + { service, packetId, _ -> + _radioConfigState.update { it.copy(userConfig = user) } + service.setRemoteOwner(packetId, user.toByteArray()) + }, + "Request setOwner error", + ) + + private fun getOwner(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteOwner(packetId, dest) }, + "Request getOwner error" + ) + + fun updateChannels( + new: List, + old: List, + ) { + val destNum = destNode.value?.num ?: return + getChannelList(new, old).forEach { setRemoteChannel(destNum, it) } + + if (destNum == myNodeNum) viewModelScope.launch { + radioConfigRepository.replaceAllSettings(new) + } + _radioConfigState.update { it.copy(channelList = new) } + } + + private fun setChannels(channelUrl: String) = viewModelScope.launch { + val new = Uri.parse(channelUrl).toChannelSet() + val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch + updateChannels(new.settingsList, old.settingsList) + } + + private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request( + destNum, + { service, packetId, dest -> + service.setRemoteChannel(packetId, dest, channel.toByteArray()) + }, + "Request setRemoteChannel error" + ) + + private fun getChannel(destNum: Int, index: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) }, + "Request getChannel error" + ) + + fun setConfig(config: ConfigProtos.Config) { + setRemoteConfig(destNode.value?.num ?: return, config) + } + + private fun setRemoteConfig(destNum: Int, config: ConfigProtos.Config) = request( + destNum, + { service, packetId, dest -> + _radioConfigState.update { it.copy(radioConfig = config) } + service.setRemoteConfig(packetId, dest, config.toByteArray()) + }, + "Request setConfig error", + ) + + private fun getConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) }, + "Request getConfig error", + ) + + fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + setModuleConfig(destNode.value?.num ?: return, config) + } + + private fun setModuleConfig(destNum: Int, config: ModuleConfigProtos.ModuleConfig) = request( + destNum, + { service, packetId, dest -> + _radioConfigState.update { it.copy(moduleConfig = config) } + service.setModuleConfig(packetId, dest, config.toByteArray()) + }, + "Request setConfig error", + ) + + private fun getModuleConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) }, + "Request getModuleConfig error", + ) + + fun setRingtone(ringtone: String) { + val destNum = destNode.value?.num ?: return + _radioConfigState.update { it.copy(ringtone = ringtone) } + meshService?.setRingtone(destNum, ringtone) + } + + private fun getRingtone(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getRingtone(packetId, dest) }, + "Request getRingtone error" + ) + + fun setCannedMessages(messages: String) { + val destNum = destNode.value?.num ?: return + _radioConfigState.update { it.copy(cannedMessageMessages = messages) } + meshService?.setCannedMessages(destNum, messages) + } + + private fun getCannedMessages(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getCannedMessages(packetId, dest) }, + "Request getCannedMessages error" + ) + + private fun requestShutdown(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestShutdown(packetId, dest) }, + "Request shutdown error" + ) + + private fun requestReboot(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestReboot(packetId, dest) }, + "Request reboot error" + ) + + private fun requestFactoryReset(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestFactoryReset(packetId, dest) }, + "Request factory reset error" + ) + + private fun requestNodedbReset(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestNodedbReset(packetId, dest) }, + "Request NodeDB reset error" + ) + + private fun sendAdminRequest(destNum: Int) { + val route = radioConfigState.value.route + _radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP) + + when (route) { + AdminRoute.REBOOT.name -> requestReboot(destNum) + AdminRoute.SHUTDOWN.name -> with(radioConfigState.value) { + if (hasMetadata() && !metadata.canShutdown) { + setResponseStateError(app.getString(R.string.cant_shutdown)) + } else { + requestShutdown(destNum) + } + } + + AdminRoute.FACTORY_RESET.name -> requestFactoryReset(destNum) + AdminRoute.NODEDB_RESET.name -> requestNodedbReset(destNum) + } + } + + private fun getSessionPasskey(destNum: Int) { + if (radioConfigState.value.hasMetadata()) { + sendAdminRequest(destNum) + } else { + getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) + setResponseStateTotal(2) + } + } + + fun setFixedPosition(position: Position) { + val destNum = destNode.value?.num ?: return + try { + meshService?.setFixedPosition(destNum, position) + } catch (ex: RemoteException) { + errormsg("Set fixed position error: ${ex.message}") + } + } + + fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0)) + + fun importProfile( + uri: Uri, + onResult: (DeviceProfile) -> Unit, + ) = viewModelScope.launch(Dispatchers.IO) { + try { + app.contentResolver.openInputStream(uri).use { inputStream -> + val bytes = inputStream?.readBytes() + val protobuf = DeviceProfile.parseFrom(bytes) + onResult(protobuf) + } + } catch (ex: Exception) { + errormsg("Import DeviceProfile error: ${ex.message}") + setResponseStateError(ex.customMessage) + } + } + + fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { + writeToUri(uri, profile) + } + + private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) { + try { + app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> + FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream -> + message.writeTo(outputStream) + } + } + setResponseStateSuccess() + } catch (ex: Exception) { + errormsg("Can't write file error: ${ex.message}") + setResponseStateError(ex.customMessage) + } + } + + fun installProfile(protobuf: DeviceProfile) = with(protobuf) { + meshService?.beginEditSettings() + if (hasLongName() || hasShortName()) { + destNode.value?.user?.let { + val user = MeshProtos.User.newBuilder() + .setId(it.id) + .setLongName(if (hasLongName()) longName else it.longName) + .setShortName(if (hasShortName()) shortName else it.shortName) + .setIsLicensed(it.isLicensed) + .build() + setOwner(user) + } + } + if (hasChannelUrl()) try { + setChannels(channelUrl) + } catch (ex: Exception) { + errormsg("DeviceProfile channel import error", ex) + setResponseStateError(ex.customMessage) + } + if (hasConfig()) { + val descriptor = ConfigProtos.Config.getDescriptor() + config.allFields.forEach { (field, value) -> + val newConfig = ConfigProtos.Config.newBuilder() + .setField(descriptor.findFieldByName(field.name), value) + .build() + setConfig(newConfig) + } + } + if (hasFixedPosition()) { + setFixedPosition(Position(fixedPosition)) + } + if (hasModuleConfig()) { + val descriptor = ModuleConfigProtos.ModuleConfig.getDescriptor() + moduleConfig.allFields.forEach { (field, value) -> + val newConfig = ModuleConfigProtos.ModuleConfig.newBuilder() + .setField(descriptor.findFieldByName(field.name), value) + .build() + setModuleConfig(newConfig) + } + } + meshService?.commitEditSettings() + } + + fun clearPacketResponse() { + requestIds.value = hashSetOf() + _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } + } + + fun setResponseStateLoading(route: Enum<*>) { + val destNum = destNode.value?.num ?: return + + _radioConfigState.value = RadioConfigState( + route = route.name, + responseState = ResponseState.Loading(), + ) + + when (route) { + ConfigRoute.USER -> getOwner(destNum) + + ConfigRoute.CHANNELS -> { + getChannel(destNum, 0) + getConfig(destNum, ConfigRoute.LORA.configType) + // channel editor is synchronous, so we don't use requestIds as total + setResponseStateTotal(maxChannels + 1) + } + + is AdminRoute -> getSessionPasskey(destNum) + + is ConfigRoute -> { + if (route == ConfigRoute.LORA) { + getChannel(destNum, 0) + } + getConfig(destNum, route.configType) + } + + is ModuleRoute -> { + if (route == ModuleRoute.CANNED_MESSAGE) { + getCannedMessages(destNum) + } + if (route == ModuleRoute.EXTERNAL_NOTIFICATION) { + getRingtone(destNum) + } + getModuleConfig(destNum, route.configType) + } + } + } + + private fun setResponseStateTotal(total: Int) { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + state.copy(responseState = state.responseState.copy(total = total)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private fun setResponseStateSuccess() { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + state.copy(responseState = ResponseState.Success(true)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private val Exception.customMessage: String get() = "${javaClass.simpleName}: $message" + private fun setResponseStateError(error: String) { + _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } + } + + private fun incrementCompleted() { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + val increment = state.responseState.completed + 1 + state.copy(responseState = state.responseState.copy(completed = increment)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private fun processPacketResponse(packet: MeshProtos.MeshPacket) { + val data = packet.decoded + if (data.requestId !in requestIds.value) return + val route = radioConfigState.value.route + + val destNum = destNode.value?.num ?: return + val debugMsg = "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s" + + if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) { + val parsed = MeshProtos.Routing.parseFrom(data.payload) + debug(debugMsg.format(parsed.errorReason.name)) + if (parsed.errorReason != MeshProtos.Routing.Error.NONE) { + setResponseStateError(app.getString(parsed.errorReason.stringRes)) + } else if (packet.from == destNum && route.isEmpty()) { + requestIds.update { it.apply { remove(data.requestId) } } + if (requestIds.value.isEmpty()) { + setResponseStateSuccess() + } else { + incrementCompleted() + } + } + } + if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) { + val parsed = AdminProtos.AdminMessage.parseFrom(data.payload) + debug(debugMsg.format(parsed.payloadVariantCase.name)) + if (destNum != packet.from) { + setResponseStateError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.") + return + } + when (parsed.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { + _radioConfigState.update { it.copy(metadata = parsed.getDeviceMetadataResponse) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + val response = parsed.getChannelResponse + // Stop once we get to the first disabled entry + if (response.role != ChannelProtos.Channel.Role.DISABLED) { + _radioConfigState.update { state -> + state.copy(channelList = state.channelList.toMutableList().apply { + add(response.index, response.settings) + }) + } + incrementCompleted() + if (response.index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { + // Not done yet, request next channel + getChannel(destNum, response.index + 1) + } + } else { + // Received last channel, update total and start channel editor + setResponseStateTotal(response.index + 1) + } + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> { + _radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + val response = parsed.getConfigResponse + if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET + setResponseStateError(response.payloadVariantCase.name) + } + _radioConfigState.update { it.copy(radioConfig = response) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> { + val response = parsed.getModuleConfigResponse + if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET + setResponseStateError(response.payloadVariantCase.name) + } + _radioConfigState.update { it.copy(moduleConfig = response) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> { + _radioConfigState.update { + it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse) + } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> { + _radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) } + incrementCompleted() + } + + else -> TODO() + } + + if (AdminRoute.entries.any { it.name == route }) { + sendAdminRequest(destNum) + } + requestIds.update { it.apply { remove(data.requestId) } } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/RouteDiscovery.kt b/app/src/main/java/com/geeksville/mesh/model/RouteDiscovery.kt new file mode 100644 index 000000000..4a8fd5dcc --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/RouteDiscovery.kt @@ -0,0 +1,63 @@ +package com.geeksville.mesh.model + +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshProtos.RouteDiscovery +import com.geeksville.mesh.Portnums + +val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery? + get() = with(decoded) { + if (hasDecoded() && !wantResponse && portnum == Portnums.PortNum.TRACEROUTE_APP) { + runCatching { RouteDiscovery.parseFrom(payload).toBuilder() }.getOrNull()?.apply { + val fullRoute = listOf(to) + routeList + from + clearRoute() + addAllRoute(fullRoute) + + val fullRouteBack = listOf(from) + routeBackList + to + clearRouteBack() + if (hopStart > 0 && snrBackCount > 0) { // otherwise back route is invalid + addAllRouteBack(fullRouteBack) + } + }?.build() + } else { + null + } + } + +@Suppress("MagicNumber") +private fun formatTraceroutePath(nodesList: List, snrList: List): String { + // nodesList should include both origin and destination nodes + // origin will not have an SNR value, but destination should + val snrStr = if (snrList.size == nodesList.size - 1) { + snrList + } else { + // use unknown SNR for entire route if snrList has invalid size + List(nodesList.size - 1) { -128 } + }.map { snr -> + val str = if (snr == -128) "?" else "${snr / 4}" + "⇊ $str dB" + } + + return nodesList.map { userName -> + "■ $userName" + }.flatMapIndexed { i, nodeStr -> + if (i == 0) listOf(nodeStr) else listOf(snrStr[i - 1], nodeStr) + }.joinToString("\n") +} + +private fun RouteDiscovery.getTracerouteResponse( + getUser: (nodeNum: Int) -> String, +): String = buildString { + if (routeList.isNotEmpty()) { + append("Route traced toward destination:\n\n") + append(formatTraceroutePath(routeList.map(getUser), snrTowardsList)) + } + if (routeBackList.isNotEmpty()) { + append("\n\n") + append("Route traced back to us:\n\n") + append(formatTraceroutePath(routeBackList.map(getUser), snrBackList)) + } +} + +fun MeshProtos.MeshPacket.getTracerouteResponse( + getUser: (nodeNum: Int) -> String, +): String? = fullRouteDiscovery?.getTracerouteResponse(getUser) diff --git a/app/src/main/java/com/geeksville/mesh/model/SortOption.kt b/app/src/main/java/com/geeksville/mesh/model/SortOption.kt new file mode 100644 index 000000000..2adf7352f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/SortOption.kt @@ -0,0 +1,13 @@ +package com.geeksville.mesh.model + +import androidx.annotation.StringRes +import com.geeksville.mesh.R + +enum class NodeSortOption(val sqlValue: String, @StringRes val stringRes: Int) { + LAST_HEARD("last_heard", R.string.node_sort_last_heard), + ALPHABETICAL("alpha", R.string.node_sort_alpha), + DISTANCE("distance", R.string.node_sort_distance), + HOPS_AWAY("hops_away", R.string.node_sort_hops_away), + CHANNEL("channel", R.string.node_sort_channel), + VIA_MQTT("via_mqtt", R.string.node_sort_via_mqtt), +} diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt new file mode 100644 index 000000000..7f9c6e130 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -0,0 +1,766 @@ +package com.geeksville.mesh.model + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.os.RemoteException +import android.view.Menu +import androidx.core.content.edit +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.* +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ConfigProtos.Config +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.PacketRepository +import com.geeksville.mesh.database.QuickChatActionRepository +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.util.positionToMeter +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.osmdroid.util.GeoPoint +import java.io.BufferedWriter +import java.io.FileNotFoundException +import java.io.FileWriter +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.roundToInt + +// Given a human name, strip out the first letter of the first three words and return that as the initials for +// that user. If the original name is only one word, strip vowels from the original name and if the result is +// 3 or more characters, use the first three characters. If not, just take the first 3 characters of the +// original name. +fun getInitials(nameIn: String): String { + val nchars = 4 + val minchars = 2 + val name = nameIn.trim() + val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() } + + val initials = when (words.size) { + in 0 until minchars -> { + val nm = if (name.isNotEmpty()) { + name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" } + } else { + "" + } + if (nm.length >= nchars) nm else name + } + else -> words.map { it.first() }.joinToString("") + } + return initials.take(nchars) +} + +/** + * Builds a [Channel] list from the difference between two [ChannelSettings] lists. + * Only changes are included in the resulting list. + * + * @param new The updated [ChannelSettings] list. + * @param old The current [ChannelSettings] list (required when disabling unused channels). + * @return A [Channel] list containing only the modified channels. + */ +internal fun getChannelList( + new: List, + old: List, +): List = buildList { + for (i in 0..maxOf(old.lastIndex, new.lastIndex)) { + if (old.getOrNull(i) != new.getOrNull(i)) add(channel { + role = when (i) { + 0 -> ChannelProtos.Channel.Role.PRIMARY + in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY + else -> ChannelProtos.Channel.Role.DISABLED + } + index = i + settings = new.getOrNull(i) ?: channelSettings { } + }) + } +} + +data class NodesUiState( + val sort: NodeSortOption = NodeSortOption.LAST_HEARD, + val filter: String = "", + val includeUnknown: Boolean = false, + val gpsFormat: Int = 0, + val distanceUnits: Int = 0, + val tempInFahrenheit: Boolean = false, + val ignoreIncomingList: List = emptyList(), + val showDetails: Boolean = false, +) { + companion object { + val Empty = NodesUiState() + } +} + +data class MapState( + val center: GeoPoint? = null, + val zoom: Double = 0.0, +) { + companion object { + val Empty = MapState() + } +} + +data class Contact( + val contactKey: String, + val shortName: String, + val longName: String, + val lastMessageTime: String?, + val lastMessageText: String?, + val unreadCount: Int, + val messageCount: Int, + val isMuted: Boolean, +) + +// return time if within 24 hours, otherwise date +private fun getShortDate(time: Long): String? { + val date = if (time != 0L) Date(time) else return null + val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1) + + return if (isWithin24Hours) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(date) + } else { + DateFormat.getDateInstance(DateFormat.SHORT).format(date) + } +} + +// return time if within 24 hours, otherwise date/time +private fun getShortDateTime(time: Long): String { + val date = Date(time) + val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1) + + return if (isWithin24Hours) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(date) + } else { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) + } +} + +@Suppress("LongParameterList") +@HiltViewModel +class UIViewModel @Inject constructor( + private val app: Application, + private val nodeDB: NodeDB, + private val radioConfigRepository: RadioConfigRepository, + private val radioInterfaceService: RadioInterfaceService, + private val meshLogRepository: MeshLogRepository, + private val packetRepository: PacketRepository, + private val quickChatActionRepository: QuickChatActionRepository, + private val preferences: SharedPreferences +) : ViewModel(), Logging { + + var actionBarMenu: Menu? = null + val meshService: IMeshService? get() = radioConfigRepository.meshService + + val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress() + val selectedBluetooth get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x' + + private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) + val localConfig: StateFlow = _localConfig + val config get() = _localConfig.value + + private val _moduleConfig = MutableStateFlow(LocalModuleConfig.getDefaultInstance()) + val moduleConfig: StateFlow = _moduleConfig + val module get() = _moduleConfig.value + + private val _channels = MutableStateFlow(channelSet {}) + val channels: StateFlow get() = _channels + + private val _quickChatActions = MutableStateFlow>(emptyList()) + val quickChatActions: StateFlow> = _quickChatActions + + private val _focusedNode = MutableStateFlow(null) + val focusedNode: StateFlow = _focusedNode + + private val nodeFilterText = MutableStateFlow("") + private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD) + private val includeUnknown = MutableStateFlow(false) + private val showDetails = MutableStateFlow(false) + + fun setSortOption(sort: NodeSortOption) { + nodeSortOption.value = sort + } + + fun toggleShowDetails() { + showDetails.value = !showDetails.value + } + + fun toggleIncludeUnknown() { + includeUnknown.value = !includeUnknown.value + } + + val nodesUiState: StateFlow = combine( + nodeFilterText, + nodeSortOption, + includeUnknown, + showDetails, + radioConfigRepository.deviceProfileFlow, + ) { filter, sort, includeUnknown, showDetails, profile -> + NodesUiState( + sort = sort, + filter = filter, + includeUnknown = includeUnknown, + gpsFormat = profile.config.display.gpsFormat.number, + distanceUnits = profile.config.display.units.number, + tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit, + ignoreIncomingList = profile.config.lora.ignoreIncomingList, + showDetails = showDetails, + ) + }.stateIn( + scope = viewModelScope, + started = Eagerly, + initialValue = NodesUiState.Empty, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> + nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) + }.stateIn( + scope = viewModelScope, + started = Eagerly, + initialValue = emptyList(), + ) + + // hardware info about our local device (can be null) + val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo + val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo + + val nodesWithPosition get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null } + + private val _mapState = MutableStateFlow(MapState.Empty) + val mapState: StateFlow get() = _mapState + + fun updateMapCenterAndZoom(center: GeoPoint, zoom: Double) = + _mapState.update { it.copy(center = center, zoom = zoom) } + + fun getUser(userId: String?) = nodeDB.getUser(userId ?: DataPacket.ID_BROADCAST) + + private val _snackbarText = MutableLiveData(null) + val snackbarText: LiveData get() = _snackbarText + + init { + radioConfigRepository.errorMessage.filterNotNull().onEach { + _snackbarText.value = it + radioConfigRepository.clearErrorMessage() + }.launchIn(viewModelScope) + + radioConfigRepository.localConfigFlow.onEach { config -> + _localConfig.value = config + }.launchIn(viewModelScope) + radioConfigRepository.moduleConfigFlow.onEach { config -> + _moduleConfig.value = config + }.launchIn(viewModelScope) + viewModelScope.launch { + quickChatActionRepository.getAllActions().collect { actions -> + _quickChatActions.value = actions + } + } + radioConfigRepository.channelSetFlow.onEach { channelSet -> + _channels.value = channelSet + }.launchIn(viewModelScope) + + debug("ViewModel created") + } + + val contactList = combine( + nodeDB.myNodeInfo, + packetRepository.getContacts(), + channels, + packetRepository.getContactSettings(), + ) { myNodeInfo, contacts, channelSet, settings -> + val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() + // Add empty channel placeholders (always show Broadcast contacts, even when empty) + val placeholder = (0 until channelSet.settingsCount).associate { ch -> + val contactKey = "$ch${DataPacket.ID_BROADCAST}" + val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) + contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) + } + + (contacts + (placeholder - contacts.keys)).values.map { packet -> + val data = packet.data + val contactKey = packet.contact_key + + // Determine if this is my message (originated on this device) + val fromLocal = data.from == DataPacket.ID_LOCAL + val toBroadcast = data.to == DataPacket.ID_BROADCAST + + // grab usernames from NodeInfo + val user = getUser(if (fromLocal) data.to else data.from) + + val shortName = user.shortName + val longName = if (toBroadcast) { + channelSet.getChannel(data.channel)?.name ?: app.getString(R.string.channel_name) + } else { + user.longName + } + + Contact( + contactKey = contactKey, + shortName = if (toBroadcast) "${data.channel}" else shortName, + longName = longName, + lastMessageTime = getShortDate(data.time), + lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", + unreadCount = packetRepository.getUnreadCount(contactKey), + messageCount = packetRepository.getMessageCount(contactKey), + isMuted = settings[contactKey]?.isMuted == true, + ) + } + }.stateIn( + scope = viewModelScope, + started = Eagerly, + initialValue = emptyList(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey).mapLatest { list -> + list.map { + Message( + uuid = it.uuid, + receivedTime = it.received_time, + user = getUser(it.data.from), + text = it.data.text.orEmpty(), + time = getShortDateTime(it.data.time), + read = it.read, + status = it.data.status, + routingError = it.routingError, + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val waypoints = packetRepository.getWaypoints().mapLatest { list -> + list.associateBy { packet -> packet.data.waypoint!!.id } + .filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 } + } + + fun generatePacketId(): Int? { + return try { + meshService?.packetId + } catch (ex: RemoteException) { + errormsg("RemoteException: ${ex.message}") + return null + } + } + + fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + // contactKey: unique contact key filter (channel)+(nodeId) + val channel = contactKey[0].digitToIntOrNull() + val dest = if (channel != null) contactKey.substring(1) else contactKey + + val p = DataPacket(dest, channel ?: 0, str) + sendDataPacket(p) + } + + fun sendWaypoint(wpt: MeshProtos.Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + // contactKey: unique contact key filter (channel)+(nodeId) + val channel = contactKey[0].digitToIntOrNull() + val dest = if (channel != null) contactKey.substring(1) else contactKey + + val p = DataPacket(dest, channel ?: 0, wpt) + if (wpt.id != 0) sendDataPacket(p) + } + + private fun sendDataPacket(p: DataPacket) { + try { + meshService?.send(p) + } catch (ex: RemoteException) { + errormsg("Send DataPacket error: ${ex.message}") + } + } + + fun requestTraceroute(destNum: Int) { + info("Requesting traceroute for '$destNum'") + try { + val packetId = meshService?.packetId ?: return + meshService?.requestTraceroute(packetId, destNum) + } catch (ex: RemoteException) { + errormsg("Request traceroute error: ${ex.message}") + } + } + + fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) { + info("Removing node '$nodeNum'") + try { + val packetId = meshService?.packetId ?: return@launch + meshService?.removeByNodenum(packetId, nodeNum) + nodeDB.deleteNode(nodeNum) + } catch (ex: RemoteException) { + errormsg("Remove node error: ${ex.message}") + } + } + + fun requestUserInfo(destNum: Int) { + info("Requesting UserInfo for '$destNum'") + try { + meshService?.requestUserInfo(destNum) + } catch (ex: RemoteException) { + errormsg("Request NodeInfo error: ${ex.message}") + } + } + + fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { + info("Requesting position for '$destNum'") + try { + meshService?.requestPosition(destNum, position) + } catch (ex: RemoteException) { + errormsg("Request position error: ${ex.message}") + } + } + + fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.setMuteUntil(contacts, until) + } + + fun deleteContacts(contacts: List) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.deleteContacts(contacts) + } + + fun deleteMessages(uuidList: List) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.deleteMessages(uuidList) + } + + fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.deleteWaypoint(id) + } + + fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.clearUnreadCount(contact, timestamp) + } + + companion object { + fun getPreferences(context: Context): SharedPreferences = + context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) + } + + // Connection state to our radio device + val connectionState get() = radioConfigRepository.connectionState.asLiveData() + fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED + + private val _requestChannelUrl = MutableLiveData(null) + val requestChannelUrl: LiveData get() = _requestChannelUrl + + fun setRequestChannelUrl(channelUrl: Uri) { + _requestChannelUrl.value = channelUrl + } + + /** + * Called immediately after activity observes requestChannelUrl + */ + fun clearRequestChannelUrl() { + _requestChannelUrl.value = null + } + + fun showSnackbar(resString: Any) { + _snackbarText.value = resString + } + + /** + * Called immediately after activity observes [snackbarText] + */ + fun clearSnackbarText() { + _snackbarText.value = null + } + + var txEnabled: Boolean + get() = config.lora.txEnabled + set(value) { + updateLoraConfig { it.copy { txEnabled = value } } + } + + var region: Config.LoRaConfig.RegionCode + get() = config.lora.region + set(value) { + updateLoraConfig { it.copy { region = value } } + } + + var ignoreIncomingList: MutableList + get() = config.lora.ignoreIncomingList + set(value) = updateLoraConfig { + it.copy { + ignoreIncoming.clear() + ignoreIncoming.addAll(value) + } + } + + // managed mode disables all access to configuration + val isManaged: Boolean get() = config.device.isManaged || config.security.isManaged + + val myNodeNum get() = myNodeInfo.value?.myNodeNum + val maxChannels get() = myNodeInfo.value?.maxChannels ?: 8 + + override fun onCleared() { + super.onCleared() + debug("ViewModel cleared") + } + + private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { + val data = body(config.lora) + setConfig(config { lora = data }) + } + + // Set the radio config (also updates our saved copy in preferences) + fun setConfig(config: Config) { + try { + meshService?.setConfig(config.toByteArray()) + } catch (ex: RemoteException) { + errormsg("Set config error:", ex) + } + } + + fun setChannel(channel: ChannelProtos.Channel) { + try { + meshService?.setChannel(channel.toByteArray()) + } catch (ex: RemoteException) { + errormsg("Set channel error:", ex) + } + } + + /** + * Set the radio config (also updates our saved copy in preferences). By default, this will replace + * all channels in the existing radio config. Otherwise, it will append all [ChannelSettings] that + * are unique in [channelSet] to the existing radio config. + */ + fun setChannels(channelSet: AppOnlyProtos.ChannelSet, overwrite: Boolean = true) = viewModelScope.launch { + val newRadioSettings: List = if (overwrite) { + channelSet.settingsList + } else { + // To guarantee consistent ordering, using a LinkedHashSet which iterates through it's + // entries according to the order an item was *first* inserted. + // https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/ + LinkedHashSet(channels.value.settingsList + channelSet.settingsList).toList() + } + + getChannelList(newRadioSettings, channels.value.settingsList).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(newRadioSettings) + val newConfig = config { lora = channelSet.loraConfig } + if (overwrite && config.lora != newConfig.lora) setConfig(newConfig) + } + + val provideLocation = object : MutableLiveData(preferences.getBoolean("provide-location", false)) { + override fun setValue(value: Boolean) { + super.setValue(value) + + preferences.edit { + this.putBoolean("provide-location", value) + } + } + } + + fun setOwner(name: String) { + val user = ourNodeInfo.value?.user?.copy { + longName = name + shortName = getInitials(name) + } ?: return + + try { + // Note: we use ?. here because we might be running in the emulator + meshService?.setRemoteOwner(myNodeNum ?: return, user.toByteArray()) + } catch (ex: RemoteException) { + errormsg("Can't set username on device, is device offline? ${ex.message}") + } + } + + /** + * Write the persisted packet data out to a CSV file in the specified location. + */ + fun saveMessagesCSV(uri: Uri) { + viewModelScope.launch(Dispatchers.Main) { + // Extract distances to this device from position messages and put (node,SNR,distance) in + // the file_uri + val myNodeNum = myNodeNum ?: return@launch + + // Capture the current node value while we're still on main thread + val nodes = nodeDB.nodeDBbyNum.value + + val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition -> + meshPosition?.let { Position(it) }.takeIf { + it?.isValid() == true + } + } + + writeToUri(uri) { writer -> + val nodePositions = mutableMapOf() + + writer.appendLine("\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"") + + // Packets are ordered by time, we keep most recent position of + // our device in localNodePosition. + val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault()) + meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet -> + // If we get a NodeInfo packet, use it to update our position data (if valid) + packet.nodeInfo?.let { nodeInfo -> + positionToPos.invoke(nodeInfo.position)?.let { + nodePositions[nodeInfo.num] = nodeInfo.position + } + } + + packet.meshPacket?.let { proto -> + // If the packet contains position data then use it to update, if valid + packet.position?.let { position -> + positionToPos.invoke(position)?.let { + nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position + } + } + + // Filter out of our results any packet that doesn't report SNR. This + // is primarily ADMIN_APP. + if (proto.rxSnr != 0.0f) { + val rxDateTime = dateFormat.format(packet.received_date) + val rxFrom = proto.from.toUInt() + val senderName = nodes[proto.from]?.user?.longName ?: "" + + // sender lat & long + val senderPosition = nodePositions[proto.from] + val senderPos = positionToPos.invoke(senderPosition) + val senderLat = senderPos?.latitude ?: "" + val senderLong = senderPos?.longitude ?: "" + + // rx lat, long, and elevation + val rxPosition = nodePositions[myNodeNum] + val rxPos = positionToPos.invoke(rxPosition) + val rxLat = rxPos?.latitude ?: "" + val rxLong = rxPos?.longitude ?: "" + val rxAlt = rxPos?.altitude ?: "" + val rxSnr = proto.rxSnr + + // Calculate the distance if both positions are valid + + val dist = if (senderPos == null || rxPos == null) { + "" + } else { + positionToMeter( + rxPosition!!, // Use rxPosition but only if rxPos was valid + senderPosition!! // Use senderPosition but only if senderPos was valid + ).roundToInt().toString() + } + + val hopLimit = proto.hopLimit + + val payload = when { + proto.decoded.portnumValue !in setOf( + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + Portnums.PortNum.RANGE_TEST_APP_VALUE, + ) -> "<${proto.decoded.portnum}>" + proto.hasDecoded() -> proto.decoded.payload.toStringUtf8() + .replace("\"", "\"\"") + proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes" + else -> "" + } + + // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload + writer.appendLine("$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"") + } + } + } + } + } + } + + private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) { + withContext(Dispatchers.IO) { + try { + app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> + FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter -> + BufferedWriter(fileWriter).use { writer -> + block.invoke(writer) + } + } + } + } catch (ex: FileNotFoundException) { + errormsg("Can't write file error: ${ex.message}") + } + } + } + + fun addQuickChatAction(name: String, value: String, mode: QuickChatAction.Mode) { + viewModelScope.launch(Dispatchers.Main) { + val action = QuickChatAction(0, name, value, mode, _quickChatActions.value.size) + quickChatActionRepository.insert(action) + } + } + + fun deleteQuickChatAction(action: QuickChatAction) { + viewModelScope.launch(Dispatchers.Main) { + quickChatActionRepository.delete(action) + } + } + + fun updateQuickChatAction( + action: QuickChatAction, + name: String?, + message: String?, + mode: QuickChatAction.Mode? + ) { + viewModelScope.launch(Dispatchers.Main) { + val newAction = QuickChatAction( + action.uuid, + name ?: action.name, + message ?: action.message, + mode ?: action.mode, + action.position + ) + quickChatActionRepository.update(newAction) + } + } + + fun updateActionPositions(actions: List) { + viewModelScope.launch(Dispatchers.Main) { + for (position in actions.indices) { + quickChatActionRepository.setItemPosition(actions[position].uuid, position) + } + } + } + + val tracerouteResponse: LiveData + get() = radioConfigRepository.tracerouteResponse.asLiveData() + + fun clearTracerouteResponse() { + radioConfigRepository.clearTracerouteResponse() + } + + private val _currentTab = MutableLiveData(0) + val currentTab: LiveData get() = _currentTab + + fun setCurrentTab(tab: Int) { + _currentTab.value = tab + } + + fun focusUserNode(node: NodeEntity?) { + _currentTab.value = 1 + _focusedNode.value = node + } + + fun setNodeFilterText(text: String) { + nodeFilterText.value = text + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt b/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt new file mode 100644 index 000000000..c8892b82f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/CustomTileSource.kt @@ -0,0 +1,195 @@ +package com.geeksville.mesh.model.map + +import org.osmdroid.tileprovider.tilesource.ITileSource +import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.tileprovider.tilesource.TileSourcePolicy +import org.osmdroid.util.MapTileIndex + + +class CustomTileSource { + + companion object { + val OPENWEATHER_RADAR = OnlineTileSourceAuth( + "Open Weather Map", 1, 22, 256, ".png", arrayOf( + "https://tile.openweathermap.org/map/" + ), "Openweathermap", + TileSourcePolicy( + 4, + TileSourcePolicy.FLAG_NO_BULK + or TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ), + "precipitation", + "" + ) + // +// val RAIN_VIEWER = object : OnlineTileSourceBase( +// "RainViewer", 1, 15, 256, ".png", arrayOf( +// "https://tilecache.rainviewer.com/v2/coverage/" +// ), "RainViewer", +// TileSourcePolicy( +// 4, +// TileSourcePolicy.FLAG_NO_BULK +// or TileSourcePolicy.FLAG_NO_PREVENTIVE +// or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL +// or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED +// ) +// ) { +// override fun getTileURLString(pMapTileIndex: Long): String { +// return baseUrl + (MapTileIndex.getZoom(pMapTileIndex) +// .toString() + "/" + MapTileIndex.getY(pMapTileIndex) +// + "/" + MapTileIndex.getX(pMapTileIndex) +// + mImageFilenameEnding) +// } +// } + + + private val ESRI_IMAGERY = object : OnlineTileSourceBase( + "ESRI World Overview", 1, 20, 256, ".jpg", arrayOf( + "https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/" + ), "Esri, Maxar, Earthstar Geographics, and the GIS User Community", + TileSourcePolicy( + 4, + TileSourcePolicy.FLAG_NO_BULK + or TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ) + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + (MapTileIndex.getZoom(pMapTileIndex) + .toString() + "/" + MapTileIndex.getY(pMapTileIndex) + + "/" + MapTileIndex.getX(pMapTileIndex) + + mImageFilenameEnding) + } + } + + private val ESRI_WORLD_TOPO = object : OnlineTileSourceBase( + "ESRI World TOPO", + 1, + 20, + 256, + ".jpg", + arrayOf( + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/" + ), + "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ", + TileSourcePolicy( + 4, + TileSourcePolicy.FLAG_NO_BULK + or TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ) + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + (MapTileIndex.getZoom(pMapTileIndex) + .toString() + "/" + MapTileIndex.getY(pMapTileIndex) + + "/" + MapTileIndex.getX(pMapTileIndex) + + mImageFilenameEnding) + } + } + private val USGS_HYDRO_CACHE = object : OnlineTileSourceBase( + "USGS Hydro Cache", + 0, + 18, + 256, + "", + arrayOf( + "https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/" + ), + "USGS", + TileSourcePolicy( + 2, + TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ) + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + (MapTileIndex.getZoom(pMapTileIndex) + .toString() + "/" + MapTileIndex.getY(pMapTileIndex) + + "/" + MapTileIndex.getX(pMapTileIndex) + + mImageFilenameEnding) + } + } + private val USGS_SHADED_RELIEF = object : OnlineTileSourceBase( + "USGS Shaded Relief Only", + 0, + 18, + 256, + "", + arrayOf( + "https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/" + ), + "USGS", + TileSourcePolicy( + 2, + TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ) + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + (MapTileIndex.getZoom(pMapTileIndex) + .toString() + "/" + MapTileIndex.getY(pMapTileIndex) + + "/" + MapTileIndex.getX(pMapTileIndex) + + mImageFilenameEnding) + } + } + + /** + * WMS TILE SERVER + * More research is required to get this to function correctly with overlays + */ + val NOAA_RADAR_WMS = NOAAWmsTileSource( + "Recent Weather Radar", + arrayOf("https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/radar_meteo_imagery_nexrad_time/MapServer/WmsServer?"), + "1", + "1.1.0", + "", + "EPSG%3A3857", + "", + "image/png" + ) + + /** + * =============================================================================================== + */ + + private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK + private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO + private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo + private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT + private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP + val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE + + /** + * Source for each available [ITileSource] and their display names. + */ + val mTileSources: Map = mapOf( + MAPNIK to "OpenStreetMap", + USGS_TOPO to "USGS TOPO", + OPEN_TOPO to "Open TOPO", + ESRI_WORLD_TOPO to "ESRI World TOPO", + USGS_SAT to "USGS Satellite", + ESRI_IMAGERY to "ESRI World Overview", + ) + + fun getTileSource(index: Int): ITileSource { + return mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE + } + + fun getTileSource(aName: String): ITileSource { + for (tileSource: ITileSource in mTileSources.keys) { + if (tileSource.name().equals(aName)) { + return tileSource + } + } + throw IllegalArgumentException("No such tile source: $aName") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt new file mode 100644 index 000000000..33b2ba00e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt @@ -0,0 +1,119 @@ +package com.geeksville.mesh.model.map + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.MotionEvent +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon + +class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) { + + companion object { + private const val LABEL_CORNER_RADIUS = 12F + private const val LABEL_Y_OFFSET = 100F + } + + private var nodeColor: Int = Color.GRAY + fun setNodeColors(colors: Pair) { + nodeColor = colors.second + } + + private var precisionBits: Int? = null + fun setPrecisionBits(bits: Int) { + precisionBits = bits + } + + @Suppress("MagicNumber") + private fun getPrecisionMeters(): Double? { + return when (precisionBits) { + 10 -> 23345.484932 + 11 -> 11672.7369 + 12 -> 5836.36288 + 13 -> 2918.175876 + 14 -> 1459.0823719999053 + 15 -> 729.53562 + 16 -> 364.7622 + 17 -> 182.375556 + 18 -> 91.182212 + 19 -> 45.58554 + else -> null + } + } + + private var onLongClickListener: (() -> Boolean)? = null + + fun setOnLongClickListener(listener: () -> Boolean) { + onLongClickListener = listener + } + + private val mLabel = label + private val mEmoji = emoji + private val textPaint = Paint().apply { + textSize = 40f + color = Color.DKGRAY + isAntiAlias = true + isFakeBoldText = true + textAlign = Paint.Align.CENTER + } + private val emojiPaint = Paint().apply { + textSize = 80f + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + private val bgPaint = Paint().apply { color = Color.WHITE } + + private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF { + val fontMetrics = textPaint.fontMetrics + val halfTextLength = textPaint.measureText(text) / 2 + 3 + return RectF( + (x - halfTextLength), + (y + fontMetrics.top), + (x + halfTextLength), + (y + fontMetrics.bottom) + ) + } + + override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { + val touched = hitTest(event, mapView) + if (touched && this.id != null) { + return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView) + } + return super.onLongPress(event, mapView) + } + + @Suppress("MagicNumber") + override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { + super.draw(c, osmv, false) + val p = mPositionPixels + val bgRect = getTextBackgroundSize(mLabel, (p.x - 0F), (p.y - LABEL_Y_OFFSET)) + bgRect.inset(-8F, -2F) + + if (mLabel.isNotEmpty()) { + c.drawRoundRect(bgRect, LABEL_CORNER_RADIUS, LABEL_CORNER_RADIUS, bgPaint) + c.drawText(mLabel, (p.x - 0F), (p.y - LABEL_Y_OFFSET), textPaint) + } + mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } + + getPrecisionMeters()?.let { radius -> + val polygon = Polygon(osmv).apply { + points = Polygon.pointsAsCircle( + position, + radius + ) + fillPaint.apply { + color = nodeColor + alpha = 48 + } + outlinePaint.apply { + color = nodeColor + alpha = 64 + } + } + polygon.draw(c, osmv, false) + } + } +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt similarity index 51% rename from app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt rename to app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt index ac438397a..a99cf1f9b 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt +++ b/app/src/main/java/com/geeksville/mesh/model/map/NOAAWmsTileSource.kt @@ -1,23 +1,7 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.model +package com.geeksville.mesh.model.map import android.content.res.Resources -import co.touchlab.kermit.Logger +import android.util.Log import org.osmdroid.api.IMapView import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase import org.osmdroid.tileprovider.tilesource.TileSourcePolicy @@ -36,40 +20,33 @@ open class NOAAWmsTileSource( style: String?, format: String, ) : OnlineTileSourceBase( - aName, - 0, - 5, - 256, - "png", - aBaseUrl, - "", - TileSourcePolicy( + aName, 0, 5, 256, "png", aBaseUrl, "", TileSourcePolicy( 2, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), + TileSourcePolicy.FLAG_NO_BULK + or TileSourcePolicy.FLAG_NO_PREVENTIVE + or TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL + or TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED + ) ) { // array indexes for array to hold bounding boxes. - private val minX = 0 - private val maxX = 1 - private val minY = 2 - private val maxY = 3 + private val MINX = 0 + private val MAXX = 1 + private val MINY = 2 + private val MAXY = 3 // Web Mercator n/w corner of the map. - private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244) + private val TILE_ORIGIN = doubleArrayOf(-20037508.34789244, 20037508.34789244) - // array indexes for that data - private val origX = 0 - private val origY = 1 // " + //array indexes for that data + private val ORIG_X = 0 + private val ORIG_Y = 1 // " // Size of square world map in meters, using WebMerc projection. - private val mapSize = 20037508.34789244 * 2 + private val MAP_SIZE = 20037508.34789244 * 2 private var layer = "" private var version = "1.1.0" - private var srs = "EPSG%3A3857" // used by geo server + private var srs = "EPSG%3A3857" //used by geo server private var format = "" private var time = "" private var style: String? = null @@ -77,7 +54,7 @@ open class NOAAWmsTileSource( private var forceHttp = false init { - Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" } + Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues") layer = layername this.version = version this.srs = srs @@ -86,7 +63,26 @@ open class NOAAWmsTileSource( if (time != null) this.time = time } - private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180 +// fun createFrom(endpoint: WMSEndpoint, layer: WMSLayer): WMSTileSource? { +// var srs: String? = "EPSG:900913" +// if (layer.srs.isNotEmpty()) { +// srs = layer.srs[0] +// } +// return if (layer.styles.isEmpty()) { +// WMSTileSource( +// layer.name, arrayOf(endpoint.baseurl), layer.name, +// endpoint.wmsVersion, srs, null, layer.pixelSize +// ) +// } else WMSTileSource( +// layer.name, arrayOf(endpoint.baseurl), layer.name, +// endpoint.wmsVersion, srs, layer.styles[0], layer.pixelSize +// ) +// } + + + private fun tile2lon(x: Int, z: Int): Double { + return x / 2.0.pow(z.toDouble()) * 360.0 - 180 + } private fun tile2lat(y: Int, z: Int): Double { val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble()) @@ -96,26 +92,30 @@ open class NOAAWmsTileSource( // Return a web Mercator bounding box given tile x/y indexes and a zoom // level. private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { - val tileSize = mapSize / 2.0.pow(zoom.toDouble()) - val minx = tileOrigin[origX] + x * tileSize - val maxx = tileOrigin[origX] + (x + 1) * tileSize - val miny = tileOrigin[origY] - (y + 1) * tileSize - val maxy = tileOrigin[origY] - y * tileSize + val tileSize = MAP_SIZE / 2.0.pow(zoom.toDouble()) + val minx = TILE_ORIGIN[ORIG_X] + x * tileSize + val maxx = TILE_ORIGIN[ORIG_X] + (x + 1) * tileSize + val miny = TILE_ORIGIN[ORIG_Y] - (y + 1) * tileSize + val maxy = TILE_ORIGIN[ORIG_Y] - y * tileSize val bbox = DoubleArray(4) - bbox[minX] = minx - bbox[minY] = miny - bbox[maxX] = maxx - bbox[maxY] = maxy + bbox[MINX] = minx + bbox[MINY] = miny + bbox[MAXX] = maxx + bbox[MAXY] = maxy return bbox } - fun isForceHttps(): Boolean = forceHttps + fun isForceHttps(): Boolean { + return forceHttps + } fun setForceHttps(forceHttps: Boolean) { this.forceHttps = forceHttps } - fun isForceHttp(): Boolean = forceHttp + fun isForceHttp(): Boolean { + return forceHttp + } fun setForceHttp(forceHttp: Boolean) { this.forceHttp = forceHttp @@ -126,7 +126,8 @@ open class NOAAWmsTileSource( if (forceHttps) baseUrl = baseUrl.replace("http://", "https://") if (forceHttp) baseUrl = baseUrl.replace("https://", "http://") val sb = StringBuilder(baseUrl) - if (!baseUrl.endsWith("&")) sb.append("service=WMS") + if (!baseUrl.endsWith("&")) + sb.append("service=WMS") sb.append("&request=GetMap") sb.append("&version=").append(version) sb.append("&layers=").append(layer) @@ -138,17 +139,16 @@ open class NOAAWmsTileSource( sb.append("&srs=").append(srs) sb.append("&size=").append(getSize()) sb.append("&bbox=") - val bbox = - getBoundingBox( - MapTileIndex.getX(pMapTileIndex), - MapTileIndex.getY(pMapTileIndex), - MapTileIndex.getZoom(pMapTileIndex), - ) - sb.append(bbox[minX]).append(",") - sb.append(bbox[minY]).append(",") - sb.append(bbox[maxX]).append(",") - sb.append(bbox[maxY]) - Logger.withTag(IMapView.LOGTAG).i { sb.toString() } + val bbox = getBoundingBox( + MapTileIndex.getX(pMapTileIndex), + MapTileIndex.getY(pMapTileIndex), + MapTileIndex.getZoom(pMapTileIndex) + ) + sb.append(bbox[MINX]).append(",") + sb.append(bbox[MINY]).append(",") + sb.append(bbox[MAXX]).append(",") + sb.append(bbox[MAXY]) + Log.i(IMapView.LOGTAG, sb.toString()) return sb.toString() } @@ -156,5 +156,6 @@ open class NOAAWmsTileSource( val height = Resources.getSystem().displayMetrics.heightPixels val width = Resources.getSystem().displayMetrics.widthPixels return "$width,$height" + } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt b/app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt new file mode 100644 index 000000000..1e4e37a39 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/OnlineTileSourceAuth.kt @@ -0,0 +1,47 @@ +package com.geeksville.mesh.model.map + +import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase +import org.osmdroid.tileprovider.tilesource.TileSourcePolicy +import org.osmdroid.util.MapTileIndex + +open class OnlineTileSourceAuth( + aName: String, + aZoomLevel: Int, + aZoomMaxLevel: Int, + aTileSizePixels: Int, + aImageFileNameEnding: String, + aBaseUrl: Array, + pCopyright: String, + tileSourcePolicy: TileSourcePolicy, + layerName: String?, + apiKey: String +) : + OnlineTileSourceBase( + aName, + aZoomLevel, + aZoomMaxLevel, + aTileSizePixels, + aImageFileNameEnding, + aBaseUrl, + pCopyright, + tileSourcePolicy + + ) { + private var layerName = "" + private var apiKey = "" + + init { + if (layerName != null) { + this.layerName = layerName + } + this.apiKey = apiKey + + } + + override fun getTileURLString(pMapTileIndex: Long): String { + return "$baseUrl$layerName/" + (MapTileIndex.getZoom(pMapTileIndex) + .toString() + "/" + MapTileIndex.getX(pMapTileIndex) + .toString() + "/" + MapTileIndex.getY(pMapTileIndex) + .toString()) + mImageFilenameEnding + "?appId=$apiKey" + } +} \ No newline at end of file diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java similarity index 87% rename from app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java rename to app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java index 38e51da52..05d07d5cf 100644 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java @@ -1,32 +1,17 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; +package com.geeksville.mesh.model.map.clustering; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Point; import android.view.MotionEvent; -import org.meshtastic.app.map.model.MarkerWithLabel; - +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.bonuspack.kml.KmlFeature; import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; import org.osmdroid.views.MapView; import org.osmdroid.views.overlay.Overlay; +import com.geeksville.mesh.model.map.MarkerWithLabel; import java.util.ArrayList; import java.util.Iterator; diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java similarity index 88% rename from app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java rename to app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java index e2710352a..954551175 100644 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java @@ -1,21 +1,4 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; +package com.geeksville.mesh.model.map.clustering; import android.content.Context; import android.graphics.Bitmap; @@ -27,12 +10,11 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.view.MotionEvent; -import org.meshtastic.app.map.model.MarkerWithLabel; - import org.osmdroid.bonuspack.R; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; import org.osmdroid.views.MapView; +import com.geeksville.mesh.model.map.MarkerWithLabel; import java.util.ArrayList; import java.util.Iterator; @@ -125,10 +107,10 @@ public class RadiusMarkerClusterer extends MarkerClusterer { Iterator it = mClonedMarkers.iterator(); while (it.hasNext()) { - MarkerWithLabel neighbor = it.next(); - double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition()); + MarkerWithLabel neighbour = it.next(); + double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition()); if (distance <= mRadiusInMeters) { - cluster.add(neighbor); + cluster.add(neighbour); it.remove(); } } diff --git a/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java similarity index 67% rename from app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java rename to app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java index 324a34b52..254020613 100644 --- a/app/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java @@ -1,26 +1,8 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.app.map.cluster; - -import org.meshtastic.app.map.model.MarkerWithLabel; +package com.geeksville.mesh.model.map.clustering; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; +import com.geeksville.mesh.model.map.MarkerWithLabel; import java.util.ArrayList; diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt new file mode 100644 index 000000000..c7f8e1066 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothBroadcastReceiver.kt @@ -0,0 +1,38 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.geeksville.mesh.util.exceptionReporter +import javax.inject.Inject + +/** + * A helper class to call onChanged when bluetooth is enabled or disabled + */ +class BluetoothBroadcastReceiver @Inject constructor( + private val bluetoothRepository: BluetoothRepository +) : BroadcastReceiver() { + internal val intentFilter get() = IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + } + + override fun onReceive(context: Context, intent: Intent) = exceptionReporter { + if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) { + when (intent.bluetoothAdapterState) { + // Simulate a disconnection if the user disables bluetooth entirely + BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState() + BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState() + } + } + if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) { + bluetoothRepository.refreshState() + } + } + + private val Intent.bluetoothAdapterState: Int + get() = getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt new file mode 100644 index 000000000..6106fba29 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothDevice.kt @@ -0,0 +1,32 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.annotation.RequiresPermission +import com.geeksville.mesh.util.registerReceiverCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +@RequiresPermission("android.permission.BLUETOOTH_CONNECT") +internal fun BluetoothDevice.createBond(context: Context): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) + trySend(state) + + // we stay registered until bonding completes (either with BONDED or NONE) + if (state != BluetoothDevice.BOND_BONDING) { + close() + } + } + } + val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + context.registerReceiverCompat(receiver, filter) + createBond() + + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt new file mode 100644 index 000000000..920f153c9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt @@ -0,0 +1,31 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +@RequiresPermission("android.permission.BLUETOOTH_SCAN") +internal fun BluetoothLeScanner.scan( + filters: List = emptyList(), + scanSettings: ScanSettings = ScanSettings.Builder().build(), +): Flow = callbackFlow { + val callback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + trySend(result) + } + + override fun onScanFailed(errorCode: Int) { + cancel("onScanFailed() called with errorCode: $errorCode") + } + } + startScan(filters, scanSettings, callback) + + awaitClose { stopScan(callback) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt new file mode 100644 index 000000000..ec4781edb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -0,0 +1,120 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.annotation.SuppressLint +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import androidx.annotation.RequiresPermission +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.android.hasBluetoothPermission +import com.geeksville.mesh.util.registerReceiverCompat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository responsible for maintaining and updating the state of Bluetooth availability. + */ +@Singleton +class BluetoothRepository @Inject constructor( + private val application: Application, + private val bluetoothAdapterLazy: dagger.Lazy, + private val bluetoothBroadcastReceiverLazy: dagger.Lazy, + private val dispatchers: CoroutineDispatchers, + private val processLifecycle: Lifecycle, +) : Logging { + private val _state = MutableStateFlow(BluetoothState( + // Assume we have permission until we get our initial state update to prevent premature + // notifications to the user. + hasPermissions = true + )) + val state: StateFlow = _state.asStateFlow() + + init { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothState() + bluetoothBroadcastReceiverLazy.get().let { receiver -> + application.registerReceiverCompat(receiver, receiver.intentFilter) + } + } + } + + fun refreshState() { + processLifecycle.coroutineScope.launch(dispatchers.default) { + updateBluetoothState() + } + } + + /** @return true for a valid Bluetooth address, false otherwise */ + fun isValid(bleAddress: String): Boolean { + return BluetoothAdapter.checkBluetoothAddress(bleAddress) + } + + fun getRemoteDevice(address: String): BluetoothDevice? { + return bluetoothAdapterLazy.get() + ?.takeIf { application.hasBluetoothPermission() && isValid(address) } + ?.getRemoteDevice(address) + } + + private fun getBluetoothLeScanner(): BluetoothLeScanner? { + return bluetoothAdapterLazy.get() + ?.takeIf { application.hasBluetoothPermission() } + ?.bluetoothLeScanner + } + + @SuppressLint("MissingPermission") + fun scan(): Flow { + val filter = ScanFilter.Builder() + // Samsung doesn't seem to filter properly by service so this can't work + // see https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960 + // and https://stackoverflow.com/a/45590493 + // .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID)) + .build() + + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + return getBluetoothLeScanner()?.scan(listOf(filter), settings) + ?.filter { it.device.name?.matches(Regex(BLE_NAME_PATTERN)) == true } ?: emptyFlow() + } + + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + fun createBond(device: BluetoothDevice): Flow = device.createBond(application) + + @SuppressLint("MissingPermission") + internal suspend fun updateBluetoothState() { + val hasPerms = application.hasBluetoothPermission() + val newState: BluetoothState = bluetoothAdapterLazy.get()?.let { adapter -> + val enabled = adapter.isEnabled + val bondedDevices = adapter.takeIf { hasPerms }?.bondedDevices ?: emptySet() + + BluetoothState( + hasPermissions = hasPerms, + enabled = enabled, + bondedDevices = if (!enabled) emptyList() + else bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true }, + ) + } ?: BluetoothState() + + _state.emit(newState) + debug("Detected our bluetooth access=$newState") + } + + companion object { + const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$" + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt new file mode 100644 index 000000000..7974b9b31 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface BluetoothRepositoryModule { + companion object { + @Provides + fun provideBluetoothManager(application: Application): BluetoothManager? { + return application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? + } + + @Provides + fun provideBluetoothAdapter(service: BluetoothManager?): BluetoothAdapter? { + return service?.adapter + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt new file mode 100644 index 000000000..95985f701 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt @@ -0,0 +1,19 @@ +package com.geeksville.mesh.repository.bluetooth + +import android.bluetooth.BluetoothDevice +import com.geeksville.mesh.util.anonymize + +/** + * A snapshot in time of the state of the bluetooth subsystem. + */ +data class BluetoothState( + /** Whether we have adequate permissions to query bluetooth state */ + val hasPermissions: Boolean = false, + /** If we have adequate permissions and bluetooth is enabled */ + val enabled: Boolean = false, + /** If enabled, a list of the currently bonded devices */ + val bondedDevices: List = emptyList() +) { + override fun toString(): String = + "BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { it.anonymize }})" +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt new file mode 100644 index 000000000..d1978340e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt @@ -0,0 +1,70 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.DataStore +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.ChannelProtos.Channel +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ConfigProtos +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import java.io.IOException +import javax.inject.Inject + +/** + * Class that handles saving and retrieving [ChannelSet] data. + */ +class ChannelSetRepository @Inject constructor( + private val channelSetStore: DataStore +) : Logging { + val channelSetFlow: Flow = channelSetStore.data + .catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + errormsg("Error reading DeviceConfig settings: ${exception.message}") + emit(ChannelSet.getDefaultInstance()) + } else { + throw exception + } + } + + suspend fun clearChannelSet() { + channelSetStore.updateData { preference -> + preference.toBuilder().clear().build() + } + } + + suspend fun clearSettings() { + channelSetStore.updateData { preference -> + preference.toBuilder().clearSettings().build() + } + } + + suspend fun addAllSettings(settingsList: List) { + channelSetStore.updateData { preference -> + preference.toBuilder().addAllSettings(settingsList).build() + } + } + + /** + * Updates the [ChannelSettings] list with the provided channel. + */ + suspend fun updateChannelSettings(channel: Channel) { + if (channel.role == Channel.Role.DISABLED) return + channelSetStore.updateData { preference -> + val builder = preference.toBuilder() + // Resize to fit channel + while (builder.settingsCount <= channel.index) { + builder.addSettings(ChannelSettings.getDefaultInstance()) + } + // use setSettings() to ensure settingsList and channel indexes match + builder.setSettings(channel.index, channel.settings).build() + } + } + + suspend fun setLoraConfig(config: ConfigProtos.Config.LoRaConfig) { + channelSetStore.updateData { preference -> + preference.toBuilder().setLoraConfig(config).build() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetSerializer.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetSerializer.kt new file mode 100644 index 000000000..89cfcbc34 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetSerializer.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer for the [ChannelSet] object defined in apponly.proto. + */ +@Suppress("BlockingMethodInNonBlockingContext") +object ChannelSetSerializer : Serializer { + override val defaultValue: ChannelSet = ChannelSet.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): ChannelSet { + try { + return ChannelSet.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: ChannelSet, output: OutputStream) = t.writeTo(output) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt new file mode 100644 index 000000000..f979a6b3a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt @@ -0,0 +1,63 @@ +package com.geeksville.mesh.repository.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object DataStoreModule { + + @Singleton + @Provides + fun provideLocalConfigDataStore(@ApplicationContext appContext: Context): DataStore { + return DataStoreFactory.create( + serializer = LocalConfigSerializer, + produceFile = { appContext.dataStoreFile("local_config.pb") }, + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { LocalConfig.getDefaultInstance() } + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + } + + @Singleton + @Provides + fun provideModuleConfigDataStore(@ApplicationContext appContext: Context): DataStore { + return DataStoreFactory.create( + serializer = ModuleConfigSerializer, + produceFile = { appContext.dataStoreFile("module_config.pb") }, + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { LocalModuleConfig.getDefaultInstance() } + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + } + + @Singleton + @Provides + fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore { + return DataStoreFactory.create( + serializer = ChannelSetSerializer, + produceFile = { appContext.dataStoreFile("channel_set.pb") }, + corruptionHandler = ReplaceFileCorruptionHandler( + produceNewData = { ChannelSet.getDefaultInstance() } + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigRepository.kt new file mode 100644 index 000000000..0d2ed409b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigRepository.kt @@ -0,0 +1,50 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.DataStore +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.ConfigProtos.Config +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import java.io.IOException +import javax.inject.Inject + +/** + * Class that handles saving and retrieving [LocalConfig] data. + */ +class LocalConfigRepository @Inject constructor( + private val localConfigStore: DataStore, +) : Logging { + val localConfigFlow: Flow = localConfigStore.data + .catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + errormsg("Error reading LocalConfig settings: ${exception.message}") + emit(LocalConfig.getDefaultInstance()) + } else { + throw exception + } + } + + suspend fun clearLocalConfig() { + localConfigStore.updateData { preference -> + preference.toBuilder().clear().build() + } + } + + /** + * Updates [LocalConfig] from each [Config] oneOf. + */ + suspend fun setLocalConfig(config: Config) = localConfigStore.updateData { + val builder = it.toBuilder() + config.allFields.forEach { (field, value) -> + val localField = it.descriptorForType.findFieldByName(field.name) + if (localField != null) { + builder.setField(localField, value) + } else { + errormsg("Error writing LocalConfig settings: ${config.payloadVariantCase}") + } + } + builder.build() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigSerializer.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigSerializer.kt new file mode 100644 index 000000000..f210493d8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/LocalConfigSerializer.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer for the [LocalConfig] object defined in localonly.proto. + */ +@Suppress("BlockingMethodInNonBlockingContext") +object LocalConfigSerializer : Serializer { + override val defaultValue: LocalConfig = LocalConfig.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): LocalConfig { + try { + return LocalConfig.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: LocalConfig, output: OutputStream) = t.writeTo(output) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigRepository.kt new file mode 100644 index 000000000..9f6d330ed --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigRepository.kt @@ -0,0 +1,50 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.DataStore +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import java.io.IOException +import javax.inject.Inject + +/** + * Class that handles saving and retrieving [LocalModuleConfig] data. + */ +class ModuleConfigRepository @Inject constructor( + private val moduleConfigStore: DataStore, +) : Logging { + val moduleConfigFlow: Flow = moduleConfigStore.data + .catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + errormsg("Error reading LocalModuleConfig settings: ${exception.message}") + emit(LocalModuleConfig.getDefaultInstance()) + } else { + throw exception + } + } + + suspend fun clearLocalModuleConfig() { + moduleConfigStore.updateData { preference -> + preference.toBuilder().clear().build() + } + } + + /** + * Updates [LocalModuleConfig] from each [ModuleConfig] oneOf. + */ + suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData { + val builder = it.toBuilder() + config.allFields.forEach { (field, value) -> + val localField = it.descriptorForType.findFieldByName(field.name) + if (localField != null) { + builder.setField(localField, value) + } else { + errormsg("Error writing LocalModuleConfig settings: ${config.payloadVariantCase}") + } + } + builder.build() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigSerializer.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigSerializer.kt new file mode 100644 index 000000000..6aa516ab6 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/ModuleConfigSerializer.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +/** + * Serializer for the [LocalModuleConfig] object defined in localonly.proto. + */ +@Suppress("BlockingMethodInNonBlockingContext") +object ModuleConfigSerializer : Serializer { + override val defaultValue: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): LocalModuleConfig { + try { + return LocalModuleConfig.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) = t.writeTo(output) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt new file mode 100644 index 000000000..36d6dbb65 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -0,0 +1,189 @@ +package com.geeksville.mesh.repository.datastore + +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.ChannelProtos.Channel +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.ConfigProtos.Config +import com.geeksville.mesh.IMeshService +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.deviceProfile +import com.geeksville.mesh.model.NodeDB +import com.geeksville.mesh.model.getChannelUrl +import com.geeksville.mesh.service.MeshService.ConnectionState +import com.geeksville.mesh.service.ServiceRepository +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** + * Class responsible for radio configuration data. + * Combines access to [nodeDB], [ChannelSet], [LocalConfig] & [LocalModuleConfig]. + */ +class RadioConfigRepository @Inject constructor( + private val serviceRepository: ServiceRepository, + private val nodeDB: NodeDB, + private val channelSetRepository: ChannelSetRepository, + private val localConfigRepository: LocalConfigRepository, + private val moduleConfigRepository: ModuleConfigRepository, +) { + val meshService: IMeshService? get() = serviceRepository.meshService + + // Connection state to our radio device + val connectionState get() = serviceRepository.connectionState + fun setConnectionState(state: ConnectionState) = serviceRepository.setConnectionState(state) + + /** + * Flow representing the unique userId of our node. + */ + val myId: StateFlow get() = nodeDB.myId + + /** + * Flow representing the [MyNodeEntity] database. + */ + val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo + + /** + * Flow representing the [NodeEntity] database. + */ + val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum + + fun getUser(nodeNum: Int) = nodeDB.getUser(nodeNum) + + suspend fun upsert(node: NodeEntity) = nodeDB.upsert(node) + suspend fun installNodeDB(mi: MyNodeEntity, nodes: List) { + nodeDB.installNodeDB(mi, nodes) + } + + /** + * Flow representing the [ChannelSet] data store. + */ + val channelSetFlow: Flow = channelSetRepository.channelSetFlow + + /** + * Clears the [ChannelSet] data in the data store. + */ + suspend fun clearChannelSet() { + channelSetRepository.clearChannelSet() + } + + /** + * Replaces the [ChannelSettings] list with a new [settingsList]. + */ + suspend fun replaceAllSettings(settingsList: List) { + channelSetRepository.clearSettings() + channelSetRepository.addAllSettings(settingsList) + } + + /** + * Updates the [ChannelSettings] list with the provided channel and returns the index of the + * admin channel after the update (if not found, returns 0). + * @param channel The [Channel] provided. + * @return the index of the admin channel after the update (if not found, returns 0). + */ + suspend fun updateChannelSettings(channel: Channel) { + return channelSetRepository.updateChannelSettings(channel) + } + + /** + * Flow representing the [LocalConfig] data store. + */ + val localConfigFlow: Flow = localConfigRepository.localConfigFlow + + /** + * Clears the [LocalConfig] data in the data store. + */ + suspend fun clearLocalConfig() { + localConfigRepository.clearLocalConfig() + } + + /** + * Updates [LocalConfig] from each [Config] oneOf. + * @param config The [Config] to be set. + */ + suspend fun setLocalConfig(config: Config) { + localConfigRepository.setLocalConfig(config) + if (config.hasLora()) channelSetRepository.setLoraConfig(config.lora) + } + + /** + * Flow representing the [LocalModuleConfig] data store. + */ + val moduleConfigFlow: Flow = moduleConfigRepository.moduleConfigFlow + + /** + * Clears the [LocalModuleConfig] data in the data store. + */ + suspend fun clearLocalModuleConfig() { + moduleConfigRepository.clearLocalModuleConfig() + } + + /** + * Updates [LocalModuleConfig] from each [ModuleConfig] oneOf. + * @param config The [ModuleConfig] to be set. + */ + suspend fun setLocalModuleConfig(config: ModuleConfig) { + moduleConfigRepository.setLocalModuleConfig(config) + } + + /** + * Flow representing the combined [DeviceProfile] protobuf. + */ + val deviceProfileFlow: Flow = combine( + nodeDB.ourNodeInfo, + channelSetFlow, + localConfigFlow, + moduleConfigFlow, + ) { node, channels, localConfig, localModuleConfig -> + deviceProfile { + node?.user?.let { + longName = it.longName + shortName = it.shortName + } + channelUrl = channels.getChannelUrl().toString() + config = localConfig + moduleConfig = localModuleConfig + if (node != null && localConfig.position.fixedPosition) { + fixedPosition = node.position + } + } + } + + val errorMessage: StateFlow get() = serviceRepository.errorMessage + + fun setErrorMessage(text: String) { + serviceRepository.setErrorMessage(text) + } + + fun clearErrorMessage() { + serviceRepository.clearErrorMessage() + } + + fun setStatusMessage(text: String) { + serviceRepository.setStatusMessage(text) + } + + val meshPacketFlow: SharedFlow get() = serviceRepository.meshPacketFlow + + suspend fun emitMeshPacket(packet: MeshPacket) = coroutineScope { + serviceRepository.emitMeshPacket(packet) + } + + val tracerouteResponse: StateFlow get() = serviceRepository.tracerouteResponse + + fun setTracerouteResponse(value: String?) { + serviceRepository.setTracerouteResponse(value) + } + + fun clearTracerouteResponse() { + setTracerouteResponse(null) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt new file mode 100644 index 000000000..9cd064814 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt @@ -0,0 +1,101 @@ +package com.geeksville.mesh.repository.location + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.app.Application +import android.location.LocationManager +import androidx.annotation.RequiresPermission +import androidx.core.location.LocationCompat +import androidx.core.location.LocationListenerCompat +import androidx.core.location.LocationManagerCompat +import androidx.core.location.LocationRequestCompat +import androidx.core.location.altitude.AltitudeConverterCompat +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocationRepository @Inject constructor( + private val context: Application, + private val locationManager: dagger.Lazy, +) : Logging { + + /** + * Status of whether the app is actively subscribed to location changes. + */ + private val _receivingLocationUpdates: MutableStateFlow = MutableStateFlow(false) + val receivingLocationUpdates: StateFlow get() = _receivingLocationUpdates + + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) + private fun LocationManager.requestLocationUpdates() = callbackFlow { + + val intervalMs = 30 * 1000L // 30 seconds + val minDistanceM = 0f + + val locationRequest = LocationRequestCompat.Builder(intervalMs) + .setMinUpdateDistanceMeters(minDistanceM) + .setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY) + .build() + + val locationListener = LocationListenerCompat { location -> + if (location.hasAltitude() && !LocationCompat.hasMslAltitude(location)) { + try { + AltitudeConverterCompat.addMslAltitudeToLocation(context, location) + } catch (e: Exception) { + errormsg("addMslAltitudeToLocation() failed", e) + } + } + // info("New location: $location") + trySend(location) + } + + val providerList = buildList { + val providers = allProviders + if (android.os.Build.VERSION.SDK_INT >= 31 && LocationManager.FUSED_PROVIDER in providers) { + add(LocationManager.FUSED_PROVIDER) + } else { + if (LocationManager.GPS_PROVIDER in providers) add(LocationManager.GPS_PROVIDER) + if (LocationManager.NETWORK_PROVIDER in providers) add(LocationManager.NETWORK_PROVIDER) + } + } + + info("Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m") + _receivingLocationUpdates.value = true + GeeksvilleApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS + + try { + providerList.forEach { provider -> + LocationManagerCompat.requestLocationUpdates( + this@requestLocationUpdates, + provider, + locationRequest, + Dispatchers.IO.asExecutor(), + locationListener, + ) + } + } catch (e: Exception) { + close(e) // in case of exception, close the Flow + } + + awaitClose { + info("Stopping location requests") + _receivingLocationUpdates.value = false + GeeksvilleApplication.analytics.track("location_stop") + + LocationManagerCompat.removeUpdates(this@requestLocationUpdates, locationListener) + } + } + + /** + * Observable flow for location updates + */ + @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) + fun getLocations() = locationManager.get().requestLocationUpdates() +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt new file mode 100644 index 000000000..c11ddfeb8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepositoryModule.kt @@ -0,0 +1,20 @@ +package com.geeksville.mesh.repository.location + +import android.content.Context +import android.location.LocationManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object LocationRepositoryModule { + + @Provides + @Singleton + fun provideLocationManager(@ApplicationContext context: Context): LocationManager = + context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt b/app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt new file mode 100644 index 000000000..2d868333f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/ConnectivityManager.kt @@ -0,0 +1,30 @@ +package com.geeksville.mesh.repository.network + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +internal fun ConnectivityManager.networkAvailable(): Flow = + allNetworks().map { it.isNotEmpty() }.distinctUntilChanged() + +internal fun ConnectivityManager.allNetworks( + networkRequest: NetworkRequest = NetworkRequest.Builder().build(), +): Flow> = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(allNetworks) + } + + override fun onLost(network: Network) { + trySend(allNetworks) + } + } + registerNetworkCallback(networkRequest, callback) + + awaitClose { unregisterNetworkCallback(callback) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt new file mode 100644 index 000000000..72fa7d0b0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt @@ -0,0 +1,144 @@ +package com.geeksville.mesh.repository.network + +import com.geeksville.mesh.MeshProtos.MqttClientProxyMessage +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.model.subscribeList +import com.geeksville.mesh.mqttClientProxyMessage +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.util.ignoreException +import com.google.protobuf.ByteString +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken +import org.eclipse.paho.client.mqttv3.MqttAsyncClient +import org.eclipse.paho.client.mqttv3.MqttAsyncClient.generateClientId +import org.eclipse.paho.client.mqttv3.MqttCallbackExtended +import org.eclipse.paho.client.mqttv3.MqttConnectOptions +import org.eclipse.paho.client.mqttv3.MqttMessage +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence +import java.net.URI +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager + +@Singleton +class MQTTRepository @Inject constructor( + private val radioConfigRepository: RadioConfigRepository, +) : Logging { + + companion object { + /** + * Quality of Service (QoS) levels in MQTT: + * - QoS 0: "at most once". Packets are sent once without validation if it has been received. + * - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server. MQTT ensures delivery, but duplicates may occur. + * - QoS 2: "exactly once". Similar to QoS 1, but with no duplicates. + */ + private const val DEFAULT_QOS = 1 + private const val DEFAULT_TOPIC_ROOT = "msh" + private const val DEFAULT_TOPIC_LEVEL = "/2/e/" + private const val JSON_TOPIC_LEVEL = "/2/json/" + private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org" + } + + private var mqttClient: MqttAsyncClient? = null + + fun disconnect() { + info("MQTT Disconnected") + mqttClient?.apply { + ignoreException { disconnect() } + close(true) + mqttClient = null + } + } + + val proxyMessageFlow: Flow = callbackFlow { + val ownerId = radioConfigRepository.myId.value ?: generateClientId() + val channelSet = radioConfigRepository.channelSetFlow.first() + val mqttConfig = radioConfigRepository.moduleConfigFlow.first().mqtt + + val sslContext = SSLContext.getInstance("TLS") + // Create a custom SSLContext that trusts all certificates + sslContext.init(null, arrayOf(TrustAllX509TrustManager()), SecureRandom()) + + val rootTopic = mqttConfig.root.ifEmpty { DEFAULT_TOPIC_ROOT } + + val connectOptions = MqttConnectOptions().apply { + userName = mqttConfig.username + password = mqttConfig.password.toCharArray() + isAutomaticReconnect = true + if (mqttConfig.tlsEnabled) { + socketFactory = sslContext.socketFactory + } + } + + val bufferOptions = DisconnectedBufferOptions().apply { + isBufferEnabled = true + bufferSize = 512 + isPersistBuffer = false + isDeleteOldestMessages = true + } + + val callback = object : MqttCallbackExtended { + override fun connectComplete(reconnect: Boolean, serverURI: String) { + info("MQTT connectComplete: $serverURI reconnect: $reconnect") + channelSet.subscribeList.ifEmpty { return }.forEach { globalId -> + subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+") + if (mqttConfig.jsonEnabled) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+") + } + subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+") + } + + override fun connectionLost(cause: Throwable) { + info("MQTT connectionLost cause: $cause") + if (cause is IllegalArgumentException) close(cause) + } + + override fun messageArrived(topic: String, message: MqttMessage) { + trySend(mqttClientProxyMessage { + this.topic = topic + data = ByteString.copyFrom(message.payload) + retained = message.isRetained + }) + } + + override fun deliveryComplete(token: IMqttDeliveryToken?) { + info("MQTT deliveryComplete messageId: ${token?.messageId}") + } + } + + val scheme = if (mqttConfig.tlsEnabled) "ssl" else "tcp" + val (host, port) = mqttConfig.address.ifEmpty { DEFAULT_SERVER_ADDRESS } + .split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) } + + mqttClient = MqttAsyncClient( + URI(scheme, null, host, port, "", "", "").toString(), + ownerId, + MemoryPersistence(), + ).apply { + setCallback(callback) + setBufferOpts(bufferOptions) + connect(connectOptions) + } + + awaitClose { disconnect() } + } + + private fun subscribe(topic: String) { + mqttClient?.subscribe(topic, DEFAULT_QOS) + info("MQTT Subscribed to topic: $topic") + } + + fun publish(topic: String, data: ByteArray, retained: Boolean) { + try { + val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained) + info("MQTT Publish messageId: ${token?.messageId}") + } catch (ex: Exception) { + errormsg("MQTT Publish error: ${ex.message}") + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt new file mode 100644 index 000000000..e4fc30496 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepository.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.network + +import android.net.ConnectivityManager +import android.net.nsd.NsdManager +import com.geeksville.mesh.android.Logging +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkRepository @Inject constructor( + private val nsdManagerLazy: dagger.Lazy, + private val connectivityManager: dagger.Lazy, +) : Logging { + + val networkAvailable get() = connectivityManager.get().networkAvailable() + + val resolvedList + get() = nsdManagerLazy.get()?.serviceList(SERVICE_TYPE, SERVICE_NAME) ?: flowOf(emptyList()) + + companion object { + // To find all available services use SERVICE_TYPE = "_services._dns-sd._udp" + internal const val SERVICE_NAME = "Meshtastic" + internal const val SERVICE_TYPE = "_https._tcp." + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt new file mode 100644 index 000000000..bb54a0134 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NetworkRepositoryModule.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.network + +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.nsd.NsdManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class NetworkRepositoryModule { + companion object { + @Provides + fun provideConnectivityManager(application: Application): ConnectivityManager { + return application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + + @Provides + fun provideNsdManager(application: Application): NsdManager? { + return application.getSystemService(Context.NSD_SERVICE) as NsdManager? + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt new file mode 100644 index 000000000..589591d4c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt @@ -0,0 +1,81 @@ +package com.geeksville.mesh.repository.network + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.resume + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun NsdManager.serviceList( + serviceType: String, + serviceName: String, +): Flow> = discoverServices(serviceType).mapLatest { serviceList -> + serviceList + .filter { it.serviceName.contains(serviceName) } + .mapNotNull { resolveService(it) } +} + +private fun NsdManager.discoverServices( + serviceType: String, + protocolType: Int = NsdManager.PROTOCOL_DNS_SD, +): Flow> = callbackFlow { + val serviceList = CopyOnWriteArrayList() + val discoveryListener = object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Start Discovery failed: Error code: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Stop Discovery failed: Error code: $errorCode") + } + + override fun onDiscoveryStarted(serviceType: String) { + } + + override fun onDiscoveryStopped(serviceType: String) { + close() + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + serviceList += serviceInfo + trySend(serviceList) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + serviceList.removeAll { it.serviceName == serviceInfo.serviceName } + trySend(serviceList) + } + } + trySend(emptyList()) // Emit an initial empty list + discoverServices(serviceType, protocolType, discoveryListener) + + awaitClose { + try { + stopServiceDiscovery(discoveryListener) + } catch (ex: IllegalArgumentException) { + // ignore if discovery is already stopped + } + } +} + +private suspend fun NsdManager.resolveService( + serviceInfo: NsdServiceInfo, +): NsdServiceInfo? = suspendCancellableCoroutine { continuation -> + val listener = object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + continuation.resume(null) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + continuation.resume(serviceInfo) + } + } + resolveService(serviceInfo, listener) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt b/app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt new file mode 100644 index 000000000..1783d7617 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt @@ -0,0 +1,12 @@ +package com.geeksville.mesh.repository.network + +import android.annotation.SuppressLint +import java.security.cert.X509Certificate +import javax.net.ssl.X509TrustManager + +@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") +class TrustAllX509TrustManager : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt new file mode 100644 index 000000000..31a85d851 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterface.kt @@ -0,0 +1,426 @@ +package com.geeksville.mesh.repository.radio + +import android.app.Application +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattService +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.service.* +import com.geeksville.mesh.util.anonymize +import com.geeksville.mesh.util.exceptionReporter +import com.geeksville.mesh.util.ignoreException +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import java.lang.reflect.Method +import java.util.* + + +/* Info for the esp32 device side code. See that source for the 'gold' standard docs on this interface. + +MeshBluetoothService UUID 6ba1b218-15a8-461f-9fa8-5dcae273eafd + +FIXME - notify vs indication for fromradio output. Using notify for now, not sure if that is best +FIXME - in the esp32 mesh management code, occasionally mirror the current net db to flash, so that if we reboot we still have a good guess of users who are out there. +FIXME - make sure this protocol is guaranteed robust and won't drop packets + +"According to the BLE specification the notification length can be max ATT_MTU - 3. The 3 bytes subtracted is the 3-byte header(OP-code (operation, 1 byte) and the attribute handle (2 bytes)). +In BLE 4.1 the ATT_MTU is 23 bytes (20 bytes for payload), but in BLE 4.2 the ATT_MTU can be negotiated up to 247 bytes." + +MAXPACKET is 256? look into what the lora lib uses. FIXME + +Characteristics: +UUID +properties +description + +8ba2bcc2-ee02-4a55-a531-c525c5e454d5 +read +fromradio - contains a newly received packet destined towards the phone (up to MAXPACKET bytes? per packet). +After reading the esp32 will put the next packet in this mailbox. If the FIFO is empty it will put an empty packet in this +mailbox. + +f75c76d2-129e-4dad-a1dd-7866124401e7 +write +toradio - write ToRadio protobufs to this charstic to send them (up to MAXPACKET len) + +ed9da18c-a800-4f66-a670-aa7547e34453 +read|notify|write +fromnum - the current packet # in the message waiting inside fromradio, if the phone sees this notify it should read messages +until it catches up with this number. + The phone can write to this register to go backwards up to FIXME packets, to handle the rare case of a fromradio packet was dropped after the esp32 +callback was called, but before it arrives at the phone. If the phone writes to this register the esp32 will discard older packets and put the next packet >= fromnum in fromradio. +When the esp32 advances fromnum, it will delay doing the notify by 100ms, in the hopes that the notify will never actally need to be sent if the phone is already pulling from fromradio. + Note: that if the phone ever sees this number decrease, it means the esp32 has rebooted. + +Re: queue management +Not all messages are kept in the fromradio queue (filtered based on SubPacket): +* only the most recent Position and User messages for a particular node are kept +* all Data SubPackets are kept +* No WantNodeNum / DenyNodeNum messages are kept +A variable keepAllPackets, if set to true will suppress this behavior and instead keep everything for forwarding to the phone (for debugging) + + */ + + + + +/** + * Handles the bluetooth link with a mesh radio device. Does not cache any device state, + * just does bluetooth comms etc... + * + * This service is not exposed outside of this process. + * + * Note - this class intentionally dumb. It doesn't understand protobuf framing etc... + * It is designed to be simple so it can be stubbed out with a simulated version as needed. + */ +class BluetoothInterface @AssistedInject constructor( + context: Application, + bluetoothRepository: BluetoothRepository, + private val service: RadioInterfaceService, + @Assisted val address: String, +) : IRadioInterface, Logging { + + companion object { + /// this service UUID is publicly visible for scanning + val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd") + + val BTM_FROMRADIO_CHARACTER: UUID = + UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002") + val BTM_TORADIO_CHARACTER: UUID = + UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7") + val BTM_FROMNUM_CHARACTER: UUID = + UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453") + + /** + * this is created in onCreate() + * We do an ugly hack of keeping it in the singleton so we can share it for the rare software update case + */ + @Volatile + var safe: SafeBluetooth? = null + } + + + /// Our BLE device + val device + get() = (safe ?: throw RadioNotConnectedException("No SafeBluetooth")).gatt + ?: throw RadioNotConnectedException("No GATT") + + /// Our service - note - it is possible to get back a null response for getService if the device services haven't yet been found + private val bservice + get(): BluetoothGattService = device.getService(BTM_SERVICE_UUID) + ?: throw RadioNotConnectedException("BLE service not found") + + private lateinit var fromNum: BluetoothGattCharacteristic + + /** + * With the new rev2 api, our first send is to start the configure readbacks. In that case, + * rather than waiting for FromNum notifies - we try to just aggressively read all of the responses. + */ + private var isFirstSend = true + + // NRF52 targets do not need the nasty force refresh hack that ESP32 needs (because they keep their + // BLE handles stable. So turn the hack off for these devices. FIXME - find a better way to know that the board is NRF52 based + // and Amazon fire devices seem to not need this hack either + // Build.MANUFACTURER != "Amazon" && + private var needForceRefresh = !address.startsWith("FD:10:04") + + init { + // Note: this call does no comms, it just creates the device object (even if the + // device is off/not connected) + val device = bluetoothRepository.getRemoteDevice(address) + if (device != null) { + info("Creating radio interface service. device=${address.anonymize}") + + // Note this constructor also does no comm + val s = SafeBluetooth(context, device) + safe = s + + startConnect() + } else { + errormsg("Bluetooth adapter not found, assuming running on the emulator!") + } + } + + + /// Send a packet/command out the radio link + override fun handleSendToRadio(p: ByteArray) { + try { + safe?.let { s -> + val uuid = BTM_TORADIO_CHARACTER + debug("queuing ${p.size} bytes to $uuid") + + // Note: we generate a new characteristic each time, because we are about to + // change the data and we want the data stored in the closure + val toRadio = getCharacteristic(uuid) + + s.asyncWriteCharacteristic(toRadio, p) { r -> + try { + r.getOrThrow() + debug("write of ${p.size} bytes to $uuid completed") + + if (isFirstSend) { + isFirstSend = false + doReadFromRadio(false) + } + } catch (ex: Exception) { + scheduleReconnect("error during asyncWriteCharacteristic - disconnecting, ${ex.message}") + } + } + } + } catch (ex: BLEException) { + scheduleReconnect("error during handleSendToRadio ${ex.message}") + } + } + + @Volatile + private var reconnectJob: Job? = null + + /** + * We had some problem, schedule a reconnection attempt (if one isn't already queued) + */ + private fun scheduleReconnect(reason: String) { + if (reconnectJob == null) { + warn("Scheduling reconnect because $reason") + reconnectJob = service.serviceScope.handledLaunch { retryDueToException() } + } else { + warn("Skipping reconnect for $reason") + } + } + + /// Attempt to read from the fromRadio mailbox, if data is found broadcast it to android apps + private fun doReadFromRadio(firstRead: Boolean) { + safe?.let { s -> + val fromRadio = getCharacteristic(BTM_FROMRADIO_CHARACTER) + s.asyncReadCharacteristic(fromRadio) { + try { + val b = it.getOrThrow() + .value.clone() // We clone the array just in case, I'm not sure if they keep reusing the array + + if (b.isNotEmpty()) { + debug("Received ${b.size} bytes from radio") + service.handleFromRadio(b) + + // Queue up another read, until we run out of packets + doReadFromRadio(firstRead) + } else { + debug("Done reading from radio, fromradio is empty") + if (firstRead) // If we just finished our initial download, now we want to start listening for notifies + startWatchingFromNum() + } + } catch (ex: BLEException) { + scheduleReconnect("error during doReadFromRadio - disconnecting, ${ex.message}") + } + } + } + } + + /** + * Android caches old services. But our service is still changing often, so force it to reread the service definitions every + * time + */ + private fun forceServiceRefresh() { + exceptionReporter { + // If the gatt has been destroyed, skip the refresh attempt + safe?.gatt?.let { gatt -> + debug("DOING FORCE REFRESH") + val refresh: Method = gatt.javaClass.getMethod("refresh") + refresh.invoke(gatt) + } + } + } + + /// We only force service refresh the _first_ time we connect to the device. Thereafter it is assumed the firmware didn't change + private var hasForcedRefresh = false + + @Volatile + var fromNumChanged = false + + private fun startWatchingFromNum() { + safe?.setNotify(fromNum, true) { + // We might get multiple notifies before we get around to reading from the radio - so just set one flag + fromNumChanged = true + service.serviceScope.handledLaunch { + try { + if (fromNumChanged) { + fromNumChanged = false + debug("fromNum changed, so we are reading new messages") + doReadFromRadio(false) + } + } catch (e: RadioNotConnectedException) { + // Don't report autobugs for this, getting an exception here is expected behavior + errormsg("Ending FromNum read, radio not connected", e) + } + } + } + } + + /** + * Some buggy BLE stacks can fail on initial connect, with either missing services or missing characteristics. If that happens we + * disconnect and try again when the device reenumerates. + */ + private suspend fun retryDueToException() = try { + /// We gracefully handle safe being null because this can occur if someone has unpaired from our device - just abandon the reconnect attempt + val s = safe + if (s != null) { + warn("Forcing disconnect and hopefully device will comeback (disabling forced refresh)") + + // The following optimization is not currently correct - because the device might be sleeping and come back with different BLE handles + // hasForcedRefresh = true // We've already tossed any old service caches, no need to do it again + + // Make sure the old connection was killed + ignoreException { + s.closeConnection() + } + + service.onDisconnect(false) // assume we will fail + delay(1500) // Give some nasty time for buggy BLE stacks to shutdown (500ms was not enough) + reconnectJob = null // Any new reconnect requests after this will be allowed to run + warn("Attempting reconnect") + if (safe != null) // check again, because we just slept for 1sec, and someone might have closed our interface + startConnect() + else + warn("Not connecting, because safe==null, someone must have closed us") + } else { + warn("Abandoning reconnect because safe==null, someone must have closed the device") + } + } catch (ex: CancellationException) { + warn("retryDueToException was cancelled") + } finally { + reconnectJob = null + } + + /// We only try to set MTU once, because some buggy implementations fail + @Volatile + private var shouldSetMtu = true + + /// For testing + @Volatile + private var isFirstTime = true + + private fun doDiscoverServicesAndInit() { + val s = safe + if (s == null) + warn("Interface is shutting down, so skipping discover") + else + s.asyncDiscoverServices { discRes -> + try { + discRes.getOrThrow() + + service.serviceScope.handledLaunch { + try { + debug("Discovered services!") + delay(1000) // android BLE is buggy and needs a 500ms sleep before calling getChracteristic, or you might get back null + + /* if (isFirstTime) { + isFirstTime = false + throw BLEException("Faking a BLE failure") + } */ + + fromNum = getCharacteristic(BTM_FROMNUM_CHARACTER) + + // We treat the first send by a client as special + isFirstSend = true + + // Now tell clients they can (finally use the api) + service.onConnect() + + // Immediately broadcast any queued packets sitting on the device + doReadFromRadio(true) + } catch (ex: BLEException) { + scheduleReconnect( + "Unexpected error in initial device enumeration, forcing disconnect $ex" + ) + } + } + } catch (ex: BLEException) { + if (s.gatt == null) + warn("GATT was closed while discovering, assume we are shutting down") + else + scheduleReconnect( + "Unexpected error discovering services, forcing disconnect $ex" + ) + } + } + } + + private fun onConnect(connRes: Result) { + // This callback is invoked after we are connected + + connRes.getOrThrow() + + service.serviceScope.handledLaunch { + info("Connected to radio!") + + if (needForceRefresh) { // Our ESP32 code doesn't properly generate "service changed" indications. Therefore we need to force a refresh on initial start + //needForceRefresh = false // In fact, because of tearing down BLE in sleep on the ESP32, our handle # assignments are not stable across sleep - so we much refetch every time + forceServiceRefresh() // this article says android should not be caching, but it does on some phones: https://punchthrough.com/attribute-caching-in-ble-advantages-and-pitfalls/ + + delay(500) // From looking at the android C code it seems that we need to give some time for the refresh message to reach that worked _before_ we try to set mtu/get services + // 200ms was not enough on an Amazon Fire + } + + // we begin by setting our MTU size as high as it can go (if we can) + if (shouldSetMtu) + safe?.asyncRequestMtu(512) { mtuRes -> + try { + mtuRes.getOrThrow() + debug("MTU change attempted") + + // throw BLEException("Test MTU set failed") + + doDiscoverServicesAndInit() + } catch (ex: BLEException) { + shouldSetMtu = false + scheduleReconnect( + "Giving up on setting MTUs, forcing disconnect $ex" + ) + } + } + else + doDiscoverServicesAndInit() + } + } + + + override fun close() { + reconnectJob?.cancel() // Cancel any queued reconnect attempts + + if (safe != null) { + info("Closing BluetoothInterface") + val s = safe + safe = + null // We do this first, because if we throw we still want to mark that we no longer have a valid connection + + try { + s?.close() + } catch (_: BLEConnectionClosing) { + warn("Ignoring BLE errors while closing") + } + } else { + debug("Radio was not connected, skipping disable") + } + } + + /// Start a connection attempt + private fun startConnect() { + // we pass in true for autoconnect - so we will autoconnect whenever the radio + // comes in range (even if we made this connect call long ago when we got powered on) + // see https://stackoverflow.com/questions/40156699/which-correct-flag-of-autoconnect-in-connectgatt-of-ble for + // more info + safe!!.asyncConnect(true, + cb = ::onConnect, + lostConnectCb = { scheduleReconnect("connection dropped") }) + } + + + /** + * Get a chracteristic, but in a safe manner because some buggy BLE implementations might return null + */ + private fun getCharacteristic(uuid: UUID) = + bservice.getCharacteristic(uuid) ?: throw BLECharacteristicNotFoundException(uuid) + +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceFactory.kt new file mode 100644 index 000000000..8c0ce5912 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceFactory.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.AssistedFactory + +/** + * Factory for creating `BluetoothInterface` instances. + */ +@AssistedFactory +interface BluetoothInterfaceFactory : InterfaceFactorySpi \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceSpec.kt new file mode 100644 index 000000000..1ed16ddb1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BluetoothInterfaceSpec.kt @@ -0,0 +1,30 @@ +package com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.util.anonymize +import javax.inject.Inject + +/** + * Bluetooth backend implementation. + */ +class BluetoothInterfaceSpec @Inject constructor( + private val factory: BluetoothInterfaceFactory, + private val bluetoothRepository: BluetoothRepository, +) : InterfaceSpec, Logging { + override fun createInterface(rest: String): BluetoothInterface { + return factory.create(rest) + } + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + override fun addressValid(rest: String): Boolean { + val allPaired = bluetoothRepository.state.value.bondedDevices + .map { it.address }.toSet() + return if (!allPaired.contains(rest)) { + warn("Ignoring stale bond to ${rest.anonymize}") + false + } else { + true + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/IRadioInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/IRadioInterface.kt new file mode 100644 index 000000000..e60b7804e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/IRadioInterface.kt @@ -0,0 +1,8 @@ +package com.geeksville.mesh.repository.radio + +import java.io.Closeable + +interface IRadioInterface : Closeable { + fun handleSendToRadio(p: ByteArray) +} + diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt new file mode 100644 index 000000000..2845675ed --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.repository.radio + +import javax.inject.Inject +import javax.inject.Provider + +/** + * Entry point for create radio backend instances given a specific address. + * + * This class is responsible for building and dissecting radio addresses based upon + * their interface type and the "rest" of the address (which varies per implementation). + */ +class InterfaceFactory @Inject constructor( + private val nopInterfaceFactory: NopInterfaceFactory, + private val specMap: Map>> +) { + internal val nopInterface by lazy { + nopInterfaceFactory.create("") + } + + fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String { + return "${interfaceId.id}$rest" + } + + fun createInterface(address: String): IRadioInterface { + val (spec, rest) = splitAddress(address) + return spec?.createInterface(rest) ?: nopInterface + } + + fun addressValid(address: String?): Boolean { + return address?.let { + val (spec, rest) = splitAddress(it) + spec?.addressValid(rest) + } ?: false + } + + private fun splitAddress(address: String): Pair?, String> { + val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it]?.get() } + val rest = address.substring(1) + return Pair(c, rest) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactorySpi.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactorySpi.kt new file mode 100644 index 000000000..320b06c1a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactorySpi.kt @@ -0,0 +1,14 @@ +package com.geeksville.mesh.repository.radio + +/** + * Radio interface factory service provider interface. Each radio backend implementation needs + * to have a factory to create new instances. These instances are specific to a particular + * address. This interface defines a common API across all radio interfaces for obtaining + * implementation instances. + * + * This is primarily used in conjunction with Dagger assisted injection for each backend + * interface type. + */ +interface InterfaceFactorySpi { + fun create(rest: String): T +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt new file mode 100644 index 000000000..20f70f695 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt @@ -0,0 +1,19 @@ +package com.geeksville.mesh.repository.radio + +/** + * Address identifiers for all supported radio backend implementations. + */ +enum class InterfaceId(val id: Char) { + BLUETOOTH('x'), + MOCK('m'), + NOP('n'), + SERIAL('s'), + TCP('t'), + ; + + companion object { + fun forIdChar(id: Char): InterfaceId? { + return entries.firstOrNull { it.id == id } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt new file mode 100644 index 000000000..c8cd5d1be --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt @@ -0,0 +1,11 @@ +package com.geeksville.mesh.repository.radio + +import dagger.MapKey + +/** + * Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key. + */ +@MapKey +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.RUNTIME) +annotation class InterfaceMapKey(val value: InterfaceId) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceSpec.kt new file mode 100644 index 000000000..91bc8b296 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceSpec.kt @@ -0,0 +1,11 @@ +package com.geeksville.mesh.repository.radio + +/** + * This interface defines the contract that all radio backend implementations must adhere to. + */ +interface InterfaceSpec { + fun createInterface(rest: String): T + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + fun addressValid(rest: String): Boolean = true +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt new file mode 100644 index 000000000..5b0dd3e60 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt @@ -0,0 +1,222 @@ +package com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.* +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.getInitials +import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.delay +import kotlin.random.Random + +private val defaultLoRaConfig = ConfigKt.loRaConfig { + usePreset = true + region = ConfigProtos.Config.LoRaConfig.RegionCode.TW +} + +private val defaultChannel = channel { + settings = Channel.default.settings + role = ChannelProtos.Channel.Role.PRIMARY +} + +/** A simulated interface that is used for testing in the simulator */ +class MockInterface @AssistedInject constructor( + private val service: RadioInterfaceService, + @Assisted val address: String, +) : IRadioInterface, Logging { + + companion object { + private const val MY_NODE = 0x42424242 + } + + private var currentPacketId = 50 + + // an infinite sequence of ints + private val packetIdSequence = generateSequence { currentPacketId++ }.iterator() + + init { + info("Starting the mock interface") + service.onConnect() // Tell clients they can use the API + } + + override fun handleSendToRadio(p: ByteArray) { + val pr = MeshProtos.ToRadio.parseFrom(p) + sendQueueStatus(pr.packet.id) + + val data = if (pr.hasPacket()) pr.packet.decoded else null + + when { + pr.wantConfigId != 0 -> sendConfigResponse(pr.wantConfigId) + data != null && data.portnum == Portnums.PortNum.ADMIN_APP -> handleAdminPacket( + pr, + AdminProtos.AdminMessage.parseFrom(data.payload) + ) + pr.hasPacket() && pr.packet.wantAck -> sendFakeAck(pr) + else -> info("Ignoring data sent to mock interface $pr") + } + } + + private fun handleAdminPacket(pr: MeshProtos.ToRadio, d: AdminProtos.AdminMessage) { + when { + d.getConfigRequest == AdminProtos.AdminMessage.ConfigType.LORA_CONFIG -> + sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { + getConfigResponse = config { lora = defaultLoRaConfig } + } + + d.getChannelRequest != 0 -> + sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { + getChannelResponse = channel { + index = d.getChannelRequest - 1 // 0 based on the response + if (d.getChannelRequest == 1) { + settings = Channel.default.settings + role = ChannelProtos.Channel.Role.PRIMARY + } + } + } + + else -> info("Ignoring admin sent to mock interface $d") + } + } + + override fun close() { + info("Closing the mock interface") + } + + /// Generate a fake text message from a node + private fun makeTextMessage(numIn: Int) = + MeshProtos.FromRadio.newBuilder().apply { + packet = MeshProtos.MeshPacket.newBuilder().apply { + id = packetIdSequence.next() + from = numIn + to = 0xffffffff.toInt() // ugly way of saying broadcast + rxTime = (System.currentTimeMillis() / 1000).toInt() + rxSnr = 1.5f + decoded = MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.TEXT_MESSAGE_APP + payload = ByteString.copyFromUtf8("This simulated node sends Hi!") + }.build() + }.build() + } + + private fun makeDataPacket(fromIn: Int, toIn: Int, data: MeshProtos.Data.Builder) = + MeshProtos.FromRadio.newBuilder().apply { + packet = MeshProtos.MeshPacket.newBuilder().apply { + id = packetIdSequence.next() + from = fromIn + to = toIn + rxTime = (System.currentTimeMillis() / 1000).toInt() + rxSnr = 1.5f + decoded = data.build() + }.build() + } + + private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = + makeDataPacket(fromIn, toIn, MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.ROUTING_APP + payload = MeshProtos.Routing.newBuilder().apply { + }.build().toByteString() + requestId = msgId + }) + + private fun sendQueueStatus(msgId: Int) = service.handleFromRadio( + fromRadio { + queueStatus = queueStatus { + res = 0 + free = 16 + meshPacketId = msgId + } + }.toByteArray() + ) + + private fun sendAdmin( + fromIn: Int, + toIn: Int, + reqId: Int, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit + ) { + val p = makeDataPacket(fromIn, toIn, MeshProtos.Data.newBuilder().apply { + portnum = Portnums.PortNum.ADMIN_APP + payload = AdminProtos.AdminMessage.newBuilder().also { + initFn(it) + }.build().toByteString() + requestId = reqId + }) + service.handleFromRadio(p.build().toByteArray()) + } + + /// Send a fake ack packet back if the sender asked for want_ack + private fun sendFakeAck(pr: MeshProtos.ToRadio) = service.serviceScope.handledLaunch { + delay(2000) + service.handleFromRadio( + makeAck(MY_NODE + 1, pr.packet.from, pr.packet.id).build().toByteArray() + ) + } + + private fun sendConfigResponse(configId: Int) { + debug("Sending mock config response") + + @Suppress("MagicNumber") + /// Generate a fake node info entry + fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = + MeshProtos.FromRadio.newBuilder().apply { + nodeInfo = MeshProtos.NodeInfo.newBuilder().apply { + num = numIn + user = MeshProtos.User.newBuilder().apply { + id = DataPacket.nodeNumToDefaultId(numIn) + longName = "Sim " + Integer.toHexString(num) + shortName = getInitials(longName) + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + }.build() + position = MeshProtos.Position.newBuilder().apply { + latitudeI = Position.degI(lat) + longitudeI = Position.degI(lon) + altitude = 35 + time = (System.currentTimeMillis() / 1000).toInt() + precisionBits = Random.nextInt(10, 19) + }.build() + }.build() + } + + // Simulated network data to feed to our app + val packets = arrayOf( + // MyNodeInfo + MeshProtos.FromRadio.newBuilder().apply { + myInfo = MeshProtos.MyNodeInfo.newBuilder().apply { + myNodeNum = MY_NODE + }.build() + }, + + MeshProtos.FromRadio.newBuilder().apply { + metadata = deviceMetadata { + firmwareVersion = "${BuildConfig.VERSION_NAME}.abcdefg" + } + }, + + // Fake NodeDB + makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas + makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson + + MeshProtos.FromRadio.newBuilder().apply { + config = config { lora = defaultLoRaConfig } + }, + + MeshProtos.FromRadio.newBuilder().apply { + channel = defaultChannel + }, + + MeshProtos.FromRadio.newBuilder().apply { + configCompleteId = configId + }, + + // Done with config response, now pretend to receive some text messages + + makeTextMessage(MY_NODE + 1) + ) + + packets.forEach { p -> + service.handleFromRadio(p.build().toByteArray()) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceFactory.kt new file mode 100644 index 000000000..0ad9c6317 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceFactory.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.AssistedFactory + +/** + * Factory for creating `MockInterface` instances. + */ +@AssistedFactory +interface MockInterfaceFactory : InterfaceFactorySpi \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceSpec.kt new file mode 100644 index 000000000..e726464fb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterfaceSpec.kt @@ -0,0 +1,17 @@ +package com.geeksville.mesh.repository.radio + +import javax.inject.Inject + +/** + * Mock interface backend implementation. + */ +class MockInterfaceSpec @Inject constructor( + private val factory: MockInterfaceFactory +) : InterfaceSpec { + override fun createInterface(rest: String): MockInterface { + return factory.create(rest) + } + + /** Return true if this address is still acceptable. For BLE that means, still bonded */ + override fun addressValid(rest: String): Boolean = true +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterface.kt new file mode 100644 index 000000000..a9be7cee1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterface.kt @@ -0,0 +1,13 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +class NopInterface @AssistedInject constructor(@Assisted val address: String) : IRadioInterface { + override fun handleSendToRadio(p: ByteArray) { + } + + override fun close() { + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceFactory.kt new file mode 100644 index 000000000..07ef9cdf7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceFactory.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.AssistedFactory + +/** + * Factory for creating `NopInterface` instances. + */ +@AssistedFactory +interface NopInterfaceFactory : InterfaceFactorySpi \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt new file mode 100644 index 000000000..e711e8841 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NopInterfaceSpec.kt @@ -0,0 +1,14 @@ +package com.geeksville.mesh.repository.radio + +import javax.inject.Inject + +/** + * No-op interface backend implementation. + */ +class NopInterfaceSpec @Inject constructor( + private val factory: NopInterfaceFactory +) : InterfaceSpec { + override fun createInterface(rest: String): NopInterface { + return factory.create(rest) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt new file mode 100644 index 000000000..3f36f614f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -0,0 +1,294 @@ +package com.geeksville.mesh.repository.radio + +import android.app.Application +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.android.BinaryLogFile +import com.geeksville.mesh.android.BuildUtils +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.repository.bluetooth.BluetoothRepository +import com.geeksville.mesh.repository.network.NetworkRepository +import com.geeksville.mesh.util.anonymize +import com.geeksville.mesh.util.ignoreException +import com.geeksville.mesh.util.toRemoteExceptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Handles the bluetooth link with a mesh radio device. Does not cache any device state, + * just does bluetooth comms etc... + * + * This service is not exposed outside of this process. + * + * Note - this class intentionally dumb. It doesn't understand protobuf framing etc... + * It is designed to be simple so it can be stubbed out with a simulated version as needed. + */ +@Singleton +class RadioInterfaceService @Inject constructor( + private val context: Application, + private val dispatchers: CoroutineDispatchers, + private val bluetoothRepository: BluetoothRepository, + private val networkRepository: NetworkRepository, + private val processLifecycle: Lifecycle, + @RadioRepositoryQualifier private val prefs: SharedPreferences, + private val interfaceFactory: InterfaceFactory, +) : Logging { + + private val _connectionState = MutableStateFlow(RadioServiceConnectionState()) + val connectionState = _connectionState.asStateFlow() + + private val _receivedData = MutableSharedFlow() + val receivedData: SharedFlow = _receivedData + + private val logSends = false + private val logReceives = false + private lateinit var sentPacketsLog: BinaryLogFile // inited in onCreate + private lateinit var receivedPacketsLog: BinaryLogFile + + val mockInterfaceAddress: String by lazy { + toInterfaceAddress(InterfaceId.MOCK, "") + } + + /** + * We recreate this scope each time we stop an interface + */ + var serviceScope = CoroutineScope(Dispatchers.IO + Job()) + + private var radioIf: IRadioInterface = NopInterface("") + + /** true if we have started our interface + * + * Note: an interface may be started without necessarily yet having a connection + */ + private var isStarted = false + + // true if our interface is currently connected to a device + private var isConnected = false + + private fun initStateListeners() { + bluetoothRepository.state.onEach { state -> + if (state.enabled) startInterface() + else if (radioIf is BluetoothInterface) stopInterface() + }.launchIn(processLifecycle.coroutineScope) + + networkRepository.networkAvailable.onEach { state -> + if (state) startInterface() + else if (radioIf is TCPInterface) stopInterface() + }.launchIn(processLifecycle.coroutineScope) + } + + companion object : Logging { + const val DEVADDR_KEY = "devAddr2" // the new name for devaddr + } + + /** + * Constructs a full radio address for the specific interface type. + */ + fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String { + return interfaceFactory.toInterfaceAddress(interfaceId, rest) + } + + val isMockInterface: Boolean by lazy { + BuildUtils.isEmulator || (context as GeeksvilleApplication).isInTestLab + } + + /** Return the device we are configured to use, or null for none + * device address strings are of the form: + * + * at + * + * where a is either x for bluetooth or s for serial + * and t is an interface specific address (macaddr or a device path) + */ + fun getDeviceAddress(): String? { + // If the user has unpaired our device, treat things as if we don't have one + var address = prefs.getString(DEVADDR_KEY, null) + + // If we are running on the emulator we default to the mock interface, so we can have some data to show to the user + if (address == null && isMockInterface) { + address = mockInterfaceAddress + } + + return address + } + + /** Like getDeviceAddress, but filtered to return only devices we are currently bonded with + * + * at + * + * where a is either x for bluetooth or s for serial + * and t is an interface specific address (macaddr or a device path) + */ + fun getBondedDeviceAddress(): String? { + // If the user has unpaired our device, treat things as if we don't have one + val address = getDeviceAddress() + return if (interfaceFactory.addressValid(address)) { + address + } else { + null + } + } + + private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) { + debug("Broadcasting connection=$isConnected") + + processLifecycle.coroutineScope.launch(dispatchers.default) { + _connectionState.emit( + RadioServiceConnectionState(isConnected, isPermanent) + ) + } + } + + // Send a packet/command out the radio link, this routine can block if it needs to + private fun handleSendToRadio(p: ByteArray) { + radioIf.handleSendToRadio(p) + } + + // Handle an incoming packet from the radio, broadcasts it as an android intent + fun handleFromRadio(p: ByteArray) { + if (logReceives) { + receivedPacketsLog.write(p) + receivedPacketsLog.flush() + } + + // ignoreException { debug("FromRadio: ${MeshProtos.FromRadio.parseFrom(p)}") } + + processLifecycle.coroutineScope.launch(dispatchers.io) { + _receivedData.emit(p) + } + } + + fun onConnect() { + if (!isConnected) { + isConnected = true + broadcastConnectionChanged(isConnected = true, isPermanent = false) + } + } + + fun onDisconnect(isPermanent: Boolean) { + if (isConnected) { + isConnected = false + broadcastConnectionChanged(isConnected = false, isPermanent = isPermanent) + } + } + + /** Start our configured interface (if it isn't already running) */ + private fun startInterface() { + if (radioIf !is NopInterface) { + warn("Can't start interface - $radioIf is already running") + } else { + val address = getBondedDeviceAddress() + if (address == null) { + warn("No bonded mesh radio, can't start interface") + } else { + info("Starting radio ${address.anonymize}") + isStarted = true + + if (logSends) { + sentPacketsLog = BinaryLogFile(context, "sent_log.pb") + } + if (logReceives) { + receivedPacketsLog = BinaryLogFile(context, "receive_log.pb") + } + + radioIf = interfaceFactory.createInterface(address) + } + } + } + + private fun stopInterface() { + val r = radioIf + info("stopping interface $r") + isStarted = false + radioIf = interfaceFactory.nopInterface + r.close() + + // cancel any old jobs and get ready for the new ones + serviceScope.cancel("stopping interface") + serviceScope = CoroutineScope(Dispatchers.IO + Job()) + + if (logSends) { + sentPacketsLog.close() + } + if (logReceives) { + receivedPacketsLog.close() + } + + // Don't broadcast disconnects if we were just using the nop device + if (r !is NopInterface) { + onDisconnect(isPermanent = true) // Tell any clients we are now offline + } + } + + /** + * Change to a new device + * + * @return true if the device changed, false if no change + */ + private fun setBondedDeviceAddress(address: String?): Boolean { + return if (getBondedDeviceAddress() == address && isStarted) { + warn("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device") + false + } else { + // Record that this use has configured a new radio + GeeksvilleApplication.analytics.track( + "mesh_bond" + ) + + // Ignore any errors that happen while closing old device + ignoreException { + stopInterface() + } + + // The device address "n" can be used to mean none + + debug("Setting bonded device to ${address.anonymize}") + + prefs.edit { + if (address == null) { + this.remove(DEVADDR_KEY) + } else { + putString(DEVADDR_KEY, address) + } + } + + // Force the service to reconnect + startInterface() + true + } + } + + fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { + setBondedDeviceAddress(deviceAddr) + } + + /** If the service is not currently connected to the radio, try to connect now. At boot the radio interface service will + * not connect to a radio until this call is received. */ + fun connect() = toRemoteExceptions { + // We don't start actually talking to our device until MeshService binds to us - this prevents + // broadcasting connection events before MeshService is ready to receive them + startInterface() + initStateListeners() + } + + fun sendToRadio(a: ByteArray) { + // Do this in the IO thread because it might take a while (and we don't care about the result code) + serviceScope.handledLaunch { handleSendToRadio(a) } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt new file mode 100644 index 000000000..ba1821739 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt @@ -0,0 +1,44 @@ +package com.geeksville.mesh.repository.radio + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoMap +import dagger.multibindings.Multibinds + +@Suppress("unused") // Used by hilt +@Module +@InstallIn(SingletonComponent::class) +abstract class RadioRepositoryModule { + + @Multibinds + abstract fun interfaceMap(): Map> + + @[Binds IntoMap InterfaceMapKey(InterfaceId.BLUETOOTH)] + abstract fun bindBluetoothInterfaceSpec(spec: BluetoothInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*> + + @[Binds IntoMap InterfaceMapKey(InterfaceId.MOCK)] + abstract fun bindMockInterfaceSpec(spec: MockInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*> + + @[Binds IntoMap InterfaceMapKey(InterfaceId.NOP)] + abstract fun bindNopInterfaceSpec(spec: NopInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*> + + @[Binds IntoMap InterfaceMapKey(InterfaceId.SERIAL)] + abstract fun bindSerialInterfaceSpec(spec: SerialInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*> + + @[Binds IntoMap InterfaceMapKey(InterfaceId.TCP)] + abstract fun bindTCPInterfaceSpec(spec: TCPInterfaceSpec): @JvmSuppressWildcards InterfaceSpec<*> + + companion object { + @Provides + @RadioRepositoryQualifier + fun provideSharedPreferences(application: Application): SharedPreferences { + return application.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryQualifier.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryQualifier.kt new file mode 100644 index 000000000..223d83b30 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryQualifier.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import javax.inject.Qualifier + +/** + * Qualifier to distinguish radio repository- specific object instances. + */ +@Qualifier +annotation class RadioRepositoryQualifier diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioServiceConnectionState.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioServiceConnectionState.kt new file mode 100644 index 000000000..1bf3f723c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioServiceConnectionState.kt @@ -0,0 +1,6 @@ +package com.geeksville.mesh.repository.radio + +data class RadioServiceConnectionState( + val isConnected: Boolean = false, + val isPermanent: Boolean = false +) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt new file mode 100644 index 000000000..54fbf3dca --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt @@ -0,0 +1,69 @@ +package com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.repository.usb.SerialConnection +import com.geeksville.mesh.repository.usb.SerialConnectionListener +import com.geeksville.mesh.repository.usb.UsbRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.atomic.AtomicReference + +/** + * An interface that assumes we are talking to a meshtastic device via USB serial + */ +class SerialInterface @AssistedInject constructor( + service: RadioInterfaceService, + private val serialInterfaceSpec: SerialInterfaceSpec, + private val usbRepository: UsbRepository, + @Assisted private val address: String, +) : StreamInterface(service), Logging { + private var connRef = AtomicReference() + + init { + connect() + } + + override fun onDeviceDisconnect(waitForStopped: Boolean) { + connRef.get()?.close(waitForStopped) + super.onDeviceDisconnect(waitForStopped) + } + + override fun connect() { + val device = serialInterfaceSpec.findSerial(address) + if (device == null) { + errormsg("Can't find device") + } else { + info("Opening $device") + val onConnect: () -> Unit = { super.connect() } + usbRepository.createSerialConnection(device, object : SerialConnectionListener { + override fun onMissingPermission() { + errormsg("Need permissions for port") + } + + override fun onConnected() { + onConnect.invoke() + } + + override fun onDataReceived(bytes: ByteArray) { + debug("Received ${bytes.size} byte(s)") + bytes.forEach(::readChar) + } + + override fun onDisconnected(thrown: Exception?) { + thrown?.let { e -> + errormsg("Serial error: $e") + } + debug("$device disconnected") + onDeviceDisconnect(false) + } + }).also { conn -> + connRef.set(conn) + conn.connect() + } + } + } + + override fun sendBytes(p: ByteArray) { + connRef.get()?.sendBytes(p) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceFactory.kt new file mode 100644 index 000000000..076c09b21 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceFactory.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.AssistedFactory + +/** + * Factory for creating `SerialInterface` instances. + */ +@AssistedFactory +interface SerialInterfaceFactory : InterfaceFactorySpi \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt new file mode 100644 index 000000000..620b6012c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterfaceSpec.kt @@ -0,0 +1,40 @@ +package com.geeksville.mesh.repository.radio + +import android.hardware.usb.UsbManager +import com.geeksville.mesh.repository.usb.UsbRepository +import com.hoho.android.usbserial.driver.UsbSerialDriver +import javax.inject.Inject + +/** + * Serial/USB interface backend implementation. + */ +class SerialInterfaceSpec @Inject constructor( + private val factory: SerialInterfaceFactory, + private val usbManager: dagger.Lazy, + private val usbRepository: UsbRepository, +) : InterfaceSpec { + override fun createInterface(rest: String): SerialInterface { + return factory.create(rest) + } + + override fun addressValid( + rest: String + ): Boolean { + usbRepository.serialDevicesWithDrivers.value.filterValues { + usbManager.get().hasPermission(it.device) + } + findSerial(rest)?.let { d -> + return usbManager.get().hasPermission(d.device) + } + return false + } + + internal fun findSerial(rest: String): UsbSerialDriver? { + val deviceMap = usbRepository.serialDevicesWithDrivers.value + return if (deviceMap.containsKey(rest)) { + deviceMap[rest]!! + } else { + deviceMap.map { (_, driver) -> driver }.firstOrNull() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt new file mode 100644 index 000000000..9e947ddcb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt @@ -0,0 +1,136 @@ +package com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.android.Logging + +/** + * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP probably) + */ +abstract class StreamInterface(protected val service: RadioInterfaceService) : + Logging, + IRadioInterface { + companion object : Logging { + private const val START1 = 0x94.toByte() + private const val START2 = 0xc3.toByte() + private const val MAX_TO_FROM_RADIO_SIZE = 512 + } + + private val debugLineBuf = kotlin.text.StringBuilder() + + /** The index of the next byte we are hoping to receive */ + private var ptr = 0 + + /** The two halves of our length */ + private var msb = 0 + private var lsb = 0 + private var packetLen = 0 + + override fun close() { + debug("Closing stream for good") + onDeviceDisconnect(true) + } + + /** Tell MeshService our device has gone away, but wait for it to come back + * + * @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the manager callbacks + * */ + protected open fun onDeviceDisconnect(waitForStopped: Boolean) { + service.onDisconnect(isPermanent = true) // if USB device disconnects it is definitely permanently gone, not sleeping) + } + + protected open fun connect() { + // Before telling mesh service, send a few START1s to wake a sleeping device + val wakeBytes = byteArrayOf(START1, START1, START1, START1) + sendBytes(wakeBytes) + + // Now tell clients they can (finally use the api) + service.onConnect() + } + + abstract fun sendBytes(p: ByteArray) + + // If subclasses need to flash at the end of a packet they can implement + open fun flushBytes() {} + + override fun handleSendToRadio(p: ByteArray) { + // This method is called from a continuation and it might show up late, so check for uart being null + + val header = ByteArray(4) + header[0] = START1 + header[1] = START2 + header[2] = (p.size shr 8).toByte() + header[3] = (p.size and 0xff).toByte() + + sendBytes(header) + sendBytes(p) + flushBytes() + } + + /** Print device serial debug output somewhere */ + private fun debugOut(b: Byte) { + when (val c = b.toChar()) { + '\r' -> { + } // ignore + '\n' -> { + debug("DeviceLog: $debugLineBuf") + debugLineBuf.clear() + } + else -> + debugLineBuf.append(c) + } + } + + private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE) + + protected fun readChar(c: Byte) { + // Assume we will be advancing our pointer + var nextPtr = ptr + 1 + + fun lostSync() { + errormsg("Lost protocol sync") + nextPtr = 0 + } + + // Deliver our current packet and restart our reader + fun deliverPacket() { + val buf = rxPacket.copyOf(packetLen) + service.handleFromRadio(buf) + + nextPtr = 0 // Start parsing the next packet + } + + when (ptr) { + 0 -> // looking for START1 + if (c != START1) { + debugOut(c) + nextPtr = 0 // Restart from scratch + } + 1 -> // Looking for START2 + if (c != START2) { + lostSync() // Restart from scratch + } + 2 -> // Looking for MSB of our 16 bit length + msb = c.toInt() and 0xff + 3 -> { // Looking for LSB of our 16 bit length + lsb = c.toInt() and 0xff + + // We've read our header, do one big read for the packet itself + packetLen = (msb shl 8) or lsb + if (packetLen > MAX_TO_FROM_RADIO_SIZE) { + lostSync() // If packet len is too long, the bytes must have been corrupted, start looking for START1 again + } else if (packetLen == 0) { + deliverPacket() // zero length packets are valid and should be delivered immediately (because there won't be a next byte of payload) + } + } + else -> { + // We are looking at the packet bytes now + rxPacket[ptr - 4] = c + + // Note: we have to check if ptr +1 is equal to packet length (for example, for a 1 byte packetlen, this code will be run with ptr of4 + if (ptr - 4 + 1 == packetLen) { + deliverPacket() + } + } + } + ptr = nextPtr + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt new file mode 100644 index 000000000..bfe56d010 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -0,0 +1,119 @@ +package com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.util.Exceptions +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.IOException +import java.io.OutputStream +import java.net.InetAddress +import java.net.Socket +import java.net.SocketTimeoutException + +class TCPInterface @AssistedInject constructor( + service: RadioInterfaceService, + @Assisted private val address: String, +) : StreamInterface(service), Logging { + + companion object { + const val MAX_RETRIES_ALLOWED = Int.MAX_VALUE + const val MIN_BACKOFF_MILLIS = 1 * 1000L // 1 second + const val MAX_BACKOFF_MILLIS = 5 * 60 * 1000L // 5 minutes + } + + private var retryCount = 1 + private var backoffDelay = MIN_BACKOFF_MILLIS + + private var socket: Socket? = null + private lateinit var outStream: OutputStream + + init { + connect() + } + + override fun sendBytes(p: ByteArray) { + outStream.write(p) + } + + override fun flushBytes() { + outStream.flush() + } + + override fun onDeviceDisconnect(waitForStopped: Boolean) { + val s = socket + if (s != null) { + debug("Closing TCP socket") + s.close() + socket = null + } + super.onDeviceDisconnect(waitForStopped) + } + + override fun connect() { + service.serviceScope.handledLaunch { + while (true) { + try { + startConnect() + } catch (ex: IOException) { + errormsg("IOException in TCP reader: $ex") + onDeviceDisconnect(false) + } catch (ex: Throwable) { + Exceptions.report(ex, "Exception in TCP reader") + onDeviceDisconnect(false) + } + + if (retryCount > MAX_RETRIES_ALLOWED) break + + debug("Reconnect attempt $retryCount in ${backoffDelay / 1000}s") + delay(backoffDelay) + + retryCount++ + backoffDelay = minOf(backoffDelay * 2, MAX_BACKOFF_MILLIS) + } + debug("Exiting TCP reader") + } + } + + // Create a socket to make the connection with the server + private suspend fun startConnect() = withContext(Dispatchers.IO) { + debug("TCP connecting to $address") + Socket(InetAddress.getByName(address), 4403).use { socket -> + socket.tcpNoDelay = true + socket.soTimeout = 500 + this@TCPInterface.socket = socket + + BufferedOutputStream(socket.getOutputStream()).use { outputStream -> + outStream = outputStream + + BufferedInputStream(socket.getInputStream()).use { inputStream -> + super.connect() + + retryCount = 1 + backoffDelay = MIN_BACKOFF_MILLIS + + var timeoutCount = 0 + while (timeoutCount < 180) try { // close after 90s of inactivity + val c = inputStream.read() + if (c == -1) { + warn("Got EOF on TCP stream") + break + } else { + timeoutCount = 0 + readChar(c.toByte()) + } + } catch (ex: SocketTimeoutException) { + timeoutCount++ + // Ignore and start another read + } + } + } + onDeviceDisconnect(false) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceFactory.kt new file mode 100644 index 000000000..ff359d1fe --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceFactory.kt @@ -0,0 +1,9 @@ +package com.geeksville.mesh.repository.radio + +import dagger.assisted.AssistedFactory + +/** + * Factory for creating `TCPInterface` instances. + */ +@AssistedFactory +interface TCPInterfaceFactory : InterfaceFactorySpi \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceSpec.kt new file mode 100644 index 000000000..651699b6a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterfaceSpec.kt @@ -0,0 +1,14 @@ +package com.geeksville.mesh.repository.radio + +import javax.inject.Inject + +/** + * TCP interface backend implementation. + */ +class TCPInterfaceSpec @Inject constructor( + private val factory: TCPInterfaceFactory +) : InterfaceSpec { + override fun createInterface(rest: String): TCPInterface { + return factory.create(rest) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt new file mode 100644 index 000000000..602a9498d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/ProbeTableProvider.kt @@ -0,0 +1,25 @@ +package com.geeksville.mesh.repository.usb + +import com.hoho.android.usbserial.driver.CdcAcmSerialDriver +import com.hoho.android.usbserial.driver.ProbeTable +import com.hoho.android.usbserial.driver.UsbSerialProber +import dagger.Reusable +import javax.inject.Inject +import javax.inject.Provider + +/** + * Creates a probe table for the USB driver. This augments the default device-to-driver + * mappings with additional known working configurations. See this package's README for + * more info. + */ +@Reusable +class ProbeTableProvider @Inject constructor() : Provider { + override fun get(): ProbeTable { + return UsbSerialProber.getDefaultProbeTable().apply { + // RAK 4631: + addProduct(9114, 32809, CdcAcmSerialDriver::class.java) + // LilyGo TBeam v1.1: + addProduct(6790, 21972, CdcAcmSerialDriver::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/README.md b/app/src/main/java/com/geeksville/mesh/repository/usb/README.md new file mode 100644 index 000000000..0b3fac3d4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/README.md @@ -0,0 +1,23 @@ +# USB Module + +This module provides a repository for acessing USB devices. + +## Device Support + +In order to be picked up, devices need to be supported by two different mechanisms: +- Android needs to be supplied with a device filter so that it knows what devices to inform + the app about. These are expressed as vendor and device IDs in `src/res/xml/device_filter.xml`. +- The USB driver library also needs to have a mapping between the vendor + device IDs and the + driver to use for communications. Many mappings are already natively supported by the driver + but unknown devices can have manual mappings added via `ProbeTableProvider`. + +The [Serial USB Terminal](https://play.google.com/store/apps/details?id=de.kai_morich.serial_usb_terminal) +app in the Google Play Store seems to be a good app for determining both the vendor and +device IDs as well as testing different underlying drivers. + + +## Testing + +When granting permissions to a USB device, the Android platform remembers the user's decision. +In order to test the permission granting logic, re-install the app. This will cause Android +to forget previously granted permissions and will re-trigger the permission acquisition logic. \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnection.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnection.kt new file mode 100644 index 000000000..906d86dee --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnection.kt @@ -0,0 +1,26 @@ +package com.geeksville.mesh.repository.usb + +/** + * USB serial connection. + */ +interface SerialConnection : AutoCloseable { + /** + * Called to initiate the serial connection. + */ + fun connect() + + /** + * Send data (asynchronously) to the serial device. If the connection is not presently + * established then the data provided is ignored / dropped. + */ + fun sendBytes(bytes: ByteArray) + + /** + * Close the USB serial connection. + * + * @param waitForStopped if true, waits for the connection to terminate before returning + */ + fun close(waitForStopped: Boolean) + + override fun close() +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt new file mode 100644 index 000000000..70641934f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt @@ -0,0 +1,95 @@ +package com.geeksville.mesh.repository.usb + +import android.hardware.usb.UsbManager +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.util.ignoreException +import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.hoho.android.usbserial.driver.UsbSerialPort +import com.hoho.android.usbserial.util.SerialInputOutputManager +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +internal class SerialConnectionImpl( + private val usbManagerLazy: dagger.Lazy, + private val device: UsbSerialDriver, + private val listener: SerialConnectionListener +) : SerialConnection, Logging { + private val port = device.ports[0] // Most devices have just one port (port 0) + private val closedLatch = CountDownLatch(1) + private val closed = AtomicBoolean(false) + private val ioRef = AtomicReference() + + override fun sendBytes(bytes: ByteArray) { + ioRef.get()?.let { + debug("writing ${bytes.size} byte(s)") + it.writeAsync(bytes) + } + } + + override fun close(waitForStopped: Boolean) { + ignoreException { + if (closed.compareAndSet(false, true)) { + ioRef.get()?.stop() + port.close() // This will cause the reader thread to exit + } + + // Allow a short amount of time for the manager to quit (so the port can be cleanly closed) + if (waitForStopped) { + debug("Waiting for USB manager to stop...") + closedLatch.await(1, TimeUnit.SECONDS) + } + } + } + + override fun close() { + close(true) + } + + override fun connect() { + // We shouldn't be able to get this far without a USB subsystem so explode if that isn't true + val usbManager = usbManagerLazy.get()!! + + val usbDeviceConnection = usbManager.openDevice(device.device) + if (usbDeviceConnection == null) { + listener.onMissingPermission() + closed.set(true) + return + } + + port.open(usbDeviceConnection) + port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + port.dtr = true + port.rts = true + + debug("Starting serial reader thread") + val io = SerialInputOutputManager(port, object : SerialInputOutputManager.Listener { + override fun onNewData(data: ByteArray) { + listener.onDataReceived(data) + } + + override fun onRunError(e: Exception?) { + closed.set(true) + ignoreException { + port.dtr = false + port.rts = false + port.close() + } + closedLatch.countDown() + listener.onDisconnected(e) + } + }).apply { + readTimeout = 200 // To save battery we only timeout ever so often + ioRef.set(this) + } + + Thread(io).apply { + isDaemon = true + priority = Thread.MAX_PRIORITY + name = "serial reader" + }.start() // No need to keep reference to thread around, we quit by asking the ioManager to quit + + listener.onConnected() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionListener.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionListener.kt new file mode 100644 index 000000000..38266c342 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionListener.kt @@ -0,0 +1,27 @@ +package com.geeksville.mesh.repository.usb + +/** + * Callbacks indicating state changes in the USB serial connection. + */ +interface SerialConnectionListener { + /** + * Unable to initiate the connection due to missing permissions. This is a terminal + * state. + */ + fun onMissingPermission() {} + + /** + * Called when a connection has been established. + */ + fun onConnected() {} + + /** + * Called when serial data is received. + */ + fun onDataReceived(bytes: ByteArray) {} + + /** + * Called when the connection has been terminated. + */ + fun onDisconnected(thrown: Exception?) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt new file mode 100644 index 000000000..fafeed7c2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbBroadcastReceiver.kt @@ -0,0 +1,46 @@ +package com.geeksville.mesh.repository.usb + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.util.exceptionReporter +import com.geeksville.mesh.util.getParcelableExtraCompat +import javax.inject.Inject + +/** + * A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are + * changed. + */ +class UsbBroadcastReceiver @Inject constructor( + private val usbRepository: UsbRepository +) : BroadcastReceiver(), Logging { + // Can be used for registering + internal val intentFilter get() = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + } + + override fun onReceive(context: Context, intent: Intent) = exceptionReporter { + val device: UsbDevice? = intent.getParcelableExtraCompat(UsbManager.EXTRA_DEVICE) + val deviceName: String = device?.deviceName ?: "unknown" + + when (intent.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + debug("USB device '$deviceName' was detached") + usbRepository.refreshState() + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + debug("USB device '$deviceName' was attached") + usbRepository.refreshState() + } + UsbManager.EXTRA_PERMISSION_GRANTED -> { + debug("USB device '$deviceName' was granted permission") + usbRepository.refreshState() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt new file mode 100644 index 000000000..a3faabf6b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbManager.kt @@ -0,0 +1,42 @@ +package com.geeksville.mesh.repository.usb + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.core.app.PendingIntentCompat +import com.geeksville.mesh.util.registerReceiverCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" + +internal fun UsbManager.requestPermission( + context: Context, + device: UsbDevice, +): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_USB_PERMISSION == intent.action) { + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + trySend(granted) + close() + } + } + } + val permissionIntent = PendingIntentCompat.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName }, + 0, + true + ) + val filter = IntentFilter(ACTION_USB_PERMISSION) + context.registerReceiverCompat(receiver, filter) + requestPermission(device, permissionIntent) + + awaitClose { context.unregisterReceiver(receiver) } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt new file mode 100644 index 000000000..0d3b863d0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepository.kt @@ -0,0 +1,91 @@ +package com.geeksville.mesh.repository.usb + +import android.app.Application +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.util.registerReceiverCompat +import com.hoho.android.usbserial.driver.UsbSerialDriver +import com.hoho.android.usbserial.driver.UsbSerialProber +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository responsible for maintaining and updating the state of USB connectivity. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class UsbRepository @Inject constructor( + private val application: Application, + private val dispatchers: CoroutineDispatchers, + private val processLifecycle: Lifecycle, + private val usbBroadcastReceiverLazy: dagger.Lazy, + private val usbManagerLazy: dagger.Lazy, + private val usbSerialProberLazy: dagger.Lazy +) : Logging { + private val _serialDevices = MutableStateFlow(emptyMap()) + + @Suppress("unused") // Retained as public API + val serialDevices = _serialDevices + .asStateFlow() + + @Suppress("unused") // Retained as public API + val serialDevicesWithDrivers = _serialDevices + .mapLatest { serialDevices -> + val serialProber = usbSerialProberLazy.get() + buildMap { + serialDevices.forEach { (k, v) -> + serialProber.probeDevice(v)?.let { driver -> + put(k, driver) + } + } + } + }.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) + + @Suppress("unused") // Retained as public API + val serialDevicesWithPermission = _serialDevices + .mapLatest { serialDevices -> + usbManagerLazy.get()?.let { usbManager -> + serialDevices.filterValues { device -> + usbManager.hasPermission(device) + } + } ?: emptyMap() + }.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) + + init { + processLifecycle.coroutineScope.launch(dispatchers.default) { + refreshStateInternal() + usbBroadcastReceiverLazy.get().let { receiver -> + application.registerReceiverCompat(receiver, receiver.intentFilter) + } + } + } + + /** + * Creates a USB serial connection to the specified USB device. State changes and data arrival + * result in async callbacks on the supplied listener. + */ + fun createSerialConnection(device: UsbSerialDriver, listener: SerialConnectionListener): SerialConnection { + return SerialConnectionImpl(usbManagerLazy, device, listener) + } + + fun requestPermission(device: UsbDevice): Flow = + usbManagerLazy.get()?.requestPermission(application, device) ?: emptyFlow() + + fun refreshState() { + processLifecycle.coroutineScope.launch(dispatchers.default) { + refreshStateInternal() + } + } + + private suspend fun refreshStateInternal() = withContext(dispatchers.default) { + _serialDevices.emit(usbManagerLazy.get()?.deviceList ?: emptyMap()) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt new file mode 100644 index 000000000..ebd616d60 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/UsbRepositoryModule.kt @@ -0,0 +1,27 @@ +package com.geeksville.mesh.repository.usb + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbManager +import com.hoho.android.usbserial.driver.ProbeTable +import com.hoho.android.usbserial.driver.UsbSerialProber +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UsbRepositoryModule { + companion object { + @Provides + fun provideUsbManager(application: Application): UsbManager? = + application.getSystemService(Context.USB_SERVICE) as UsbManager? + + @Provides + fun provideProbeTable(provider: ProbeTableProvider): ProbeTable = provider.get() + + @Provides + fun provideUsbSerialProber(probeTable: ProbeTable): UsbSerialProber = UsbSerialProber(probeTable) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/BLEException.kt b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt new file mode 100644 index 000000000..68f7c6c4d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/BLEException.kt @@ -0,0 +1,12 @@ +package com.geeksville.mesh.service + +import java.io.IOException +import java.util.* + +open class BLEException(msg: String) : IOException(msg) + +open class BLECharacteristicNotFoundException(uuid: UUID) : + BLEException("Can't get characteristic $uuid") + +/// Our interface is being shut down +open class BLEConnectionClosing : BLEException("Connection closing ") \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/BootCompleteReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/BootCompleteReceiver.kt new file mode 100644 index 000000000..089c574e7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/BootCompleteReceiver.kt @@ -0,0 +1,14 @@ +package com.geeksville.mesh.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.geeksville.mesh.android.Logging + + +class BootCompleteReceiver : BroadcastReceiver(), Logging { + override fun onReceive(mContext: Context, intent: Intent) { + // start listening for bluetooth messages from our device + MeshService.startServiceLater(mContext) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/Constants.kt b/app/src/main/java/com/geeksville/mesh/service/Constants.kt new file mode 100644 index 000000000..3b99c917a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/Constants.kt @@ -0,0 +1,20 @@ +package com.geeksville.mesh.service + +const val prefix = "com.geeksville.mesh" + + +// +// standard EXTRA bundle definitions +// + +// a bool true means now connected, false means not +const val EXTRA_CONNECTED = "$prefix.Connected" +const val EXTRA_PROGRESS = "$prefix.Progress" + +/// a bool true means we expect this condition to continue until, false means device might come back +const val EXTRA_PERMANENT = "$prefix.Permanent" + +const val EXTRA_PAYLOAD = "$prefix.Payload" +const val EXTRA_NODEINFO = "$prefix.NodeInfo" +const val EXTRA_PACKET_ID = "$prefix.PacketId" +const val EXTRA_STATUS = "$prefix.Status" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt new file mode 100644 index 000000000..898ef4bdb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -0,0 +1,2021 @@ +package com.geeksville.mesh.service + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import android.os.RemoteException +import androidx.core.app.ServiceCompat +import androidx.core.location.LocationCompat +import com.geeksville.mesh.* +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.MeshProtos.ToRadio +import com.geeksville.mesh.TelemetryProtos.LocalStats +import com.geeksville.mesh.analytics.DataPair +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.android.hasLocationPermission +import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.PacketRepository +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.database.entity.toNodeInfo +import com.geeksville.mesh.model.DeviceVersion +import com.geeksville.mesh.model.getTracerouteResponse +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.repository.location.LocationRepository +import com.geeksville.mesh.repository.network.MQTTRepository +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.repository.radio.RadioServiceConnectionState +import com.geeksville.mesh.util.* +import com.google.protobuf.ByteString +import com.google.protobuf.InvalidProtocolBufferException +import dagger.Lazy +import dagger.hilt.android.AndroidEntryPoint +import java8.util.concurrent.CompletableFuture +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withTimeoutOrNull +import java.util.Random +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import javax.inject.Inject +import kotlin.math.absoluteValue + +/** + * Handles all the communication with android apps. Also keeps an internal model + * of the network state. + * + * Note: this service will go away once all clients are unbound from it. + * Warning: do not override toString, it causes infinite recursion on some androids (because contextWrapper.getResources calls to string + */ +@AndroidEntryPoint +class MeshService : Service(), Logging { + @Inject + lateinit var dispatchers: CoroutineDispatchers + + @Inject + lateinit var packetRepository: Lazy + + @Inject + lateinit var meshLogRepository: Lazy + + @Inject + lateinit var radioInterfaceService: RadioInterfaceService + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var radioConfigRepository: RadioConfigRepository + + @Inject + lateinit var mqttRepository: MQTTRepository + + companion object : Logging { + + // Intents broadcast by MeshService + + private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" + + // generate a RECEIVED action filter string that includes either the portnumber as an int, or preferably a symbolic name from portnums.proto + fun actionReceived(portNum: Int): String { + val portType = Portnums.PortNum.forNumber(portNum) + val portStr = portType?.toString() ?: portNum.toString() + + return actionReceived(portStr) + } + + const val ACTION_NODE_CHANGE = "$prefix.NODE_CHANGE" + const val ACTION_MESH_CONNECTED = "$prefix.MESH_CONNECTED" + const val ACTION_MESSAGE_STATUS = "$prefix.MESSAGE_STATUS" + + open class NodeNotFoundException(reason: String) : Exception(reason) + class InvalidNodeIdException(id: String) : NodeNotFoundException("Invalid NodeId $id") + class NodeNumNotFoundException(id: Int) : NodeNotFoundException("NodeNum not found $id") + class IdNotFoundException(id: String) : NodeNotFoundException("ID not found $id") + + class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : + RadioNotConnectedException(message) + + /** + * Talk to our running service and try to set a new device address. And then immediately + * call start on the service to possibly promote our service to be a foreground service. + */ + fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { + service.setDeviceAddress(address) + startService(context) + } + + fun createIntent() = Intent().setClassName( + "com.geeksville.mesh", + "com.geeksville.mesh.service.MeshService" + ) + + /** The minimum firmware version we know how to talk to. We'll still be able + * to talk to 2.0 firmwares but only well enough to ask them to firmware update. + */ + val minDeviceVersion = DeviceVersion("2.3.2") + } + + enum class ConnectionState { + DISCONNECTED, + CONNECTED, + DEVICE_SLEEP, // device is in LS sleep state, it will reconnected to us over bluetooth once it has data + ; + + fun isDisconnected() = this == DISCONNECTED + } + + private var previousSummary: String? = null + private var previousStats: LocalStats? = null + + // A mapping of receiver class name to package name - used for explicit broadcasts + private val clientPackages = mutableMapOf() + private val serviceNotifications = MeshServiceNotifications(this) + private val serviceBroadcasts = MeshServiceBroadcasts(this, clientPackages) { + connectionState.also { radioConfigRepository.setConnectionState(it) } + } + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private var connectionState = ConnectionState.DISCONNECTED + + private var locationFlow: Job? = null + private var mqttMessageFlow: Job? = null + + private fun getSenderName(packet: DataPacket?): String { + val name = nodeDBbyID[packet?.from]?.user?.longName + return name ?: getString(R.string.unknown_username) + } + + private val notificationSummary + get() = when (connectionState) { + ConnectionState.CONNECTED -> getString(R.string.connected_count).format( + numOnlineNodes, + numNodes + ) + ConnectionState.DISCONNECTED -> getString(R.string.disconnected) + ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) + } + + private var localStatsTelemetry: TelemetryProtos.Telemetry? = null + private val localStats: LocalStats? get() = localStatsTelemetry?.localStats + private val localStatsUpdatedAtMillis: Long? get() = localStatsTelemetry?.time?.let { it * 1000L } + + /** + * start our location requests (if they weren't already running) + */ + private fun startLocationRequests() { + // If we're already observing updates, don't register again + if (locationFlow?.isActive == true) return + + @SuppressLint("MissingPermission") + if (hasLocationPermission()) { + locationFlow = locationRepository.getLocations().onEach { location -> + sendPosition( + position { + latitudeI = Position.degI(location.latitude) + longitudeI = Position.degI(location.longitude) + if (LocationCompat.hasMslAltitude(location)) { + altitude = LocationCompat.getMslAltitudeMeters(location).toInt() + } + altitudeHae = location.altitude.toInt() + time = (location.time / 1000).toInt() + groundSpeed = location.speed.toInt() + groundTrack = location.bearing.toInt() + locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL + } + ) + }.launchIn(serviceScope) + } + } + + private fun stopLocationRequests() { + if (locationFlow?.isActive == true) { + info("Stopping location requests") + locationFlow?.cancel() + locationFlow = null + } + } + + /** Send a command/packet to our radio. But cope with the possibility that we might start up + before we are fully bound to the RadioInterfaceService + */ + private fun sendToRadio(p: ToRadio.Builder) { + val built = p.build() + debug("Sending to radio ${built.toPIIString()}") + val b = built.toByteArray() + + radioInterfaceService.sendToRadio(b) + changeStatus(p.packet.id, MessageStatus.ENROUTE) + + if (p.packet.hasDecoded()) { + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = p.packet.toString(), + fromNum = p.packet.from, + portNum = p.packet.decoded.portnumValue, + fromRadio = fromRadio { packet = p.packet }, + ) + insertMeshLog(packetToSave) + } + } + + /** + * Send a mesh packet to the radio, if the radio is not currently connected this function will throw NotConnectedException + */ + private fun sendToRadio(packet: MeshPacket) { + queuedPackets.add(packet) + startPacketQueue() + } + + private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { + val message: String = when (dataPacket.dataType) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! + Portnums.PortNum.WAYPOINT_APP_VALUE -> { + getString(R.string.waypoint_received, dataPacket.waypoint!!.name) + } + + else -> return + } + serviceNotifications.updateMessageNotification(contactKey, getSenderName(dataPacket), message) + } + + override fun onCreate() { + super.onCreate() + + info("Creating mesh service") + + // Switch to the IO thread + serviceScope.handledLaunch { + radioInterfaceService.connect() + } + radioInterfaceService.connectionState.onEach(::onRadioConnectionState) + .launchIn(serviceScope) + radioInterfaceService.receivedData.onEach(::onReceiveFromRadio) + .launchIn(serviceScope) + radioConfigRepository.localConfigFlow.onEach { localConfig = it } + .launchIn(serviceScope) + radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it } + .launchIn(serviceScope) + radioConfigRepository.channelSetFlow.onEach { channelSet = it } + .launchIn(serviceScope) + + loadSettings() // Load our last known node DB + + // the rest of our init will happen once we are in radioConnection.onServiceConnected + } + + /** + * If someone binds to us, this will be called after on create + */ + override fun onBind(intent: Intent?): IBinder { + return binder + } + + /** + * If someone starts us (or restarts us) this will be called after onCreate) + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val a = radioInterfaceService.getBondedDeviceAddress() + val wantForeground = a != null && a != "n" + + info("Requesting foreground service=$wantForeground") + + // We always start foreground because that's how our service is always started (if we didn't then android would kill us) + // but if we don't really need foreground we immediately stop it. + val notification = serviceNotifications.createServiceStateNotification(notificationSummary) + + try { + ServiceCompat.startForeground( + this, + serviceNotifications.notifyId, + notification, + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + } else { + 0 + }, + ) + } catch (ex: Exception) { + errormsg("startForeground failed", ex) + return START_NOT_STICKY + } + return if (!wantForeground) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + START_NOT_STICKY + } else { + START_STICKY + } + } + + override fun onDestroy() { + info("Destroying mesh service") + + // Make sure we aren't using the notification first + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + + super.onDestroy() + serviceJob.cancel() + } + + // + // BEGINNING OF MODEL - FIXME, move elsewhere + // + + private fun loadSettings() { + discardNodeDB() // Get rid of any old state + myNodeInfo = radioConfigRepository.myNodeInfo.value + nodeDBbyNodeNum.putAll(radioConfigRepository.nodeDBbyNum.value) + // Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint) + } + + /** + * discard entire node db & message state - used when downloading a new db from the device + */ + private fun discardNodeDB() { + debug("Discarding NodeDB") + myNodeInfo = null + nodeDBbyNodeNum.clear() + haveNodeDB = false + } + + private var myNodeInfo: MyNodeEntity? = null + + private val configTotal by lazy { ConfigProtos.Config.getDescriptor().fields.size } + private val moduleTotal by lazy { ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size } + private var sessionPasskey: ByteString = ByteString.EMPTY + + private var localConfig: LocalConfig = LocalConfig.getDefaultInstance() + private var moduleConfig: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() + private var channelSet: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() + + // True after we've done our initial node db init + @Volatile + private var haveNodeDB = false + + // The database of active nodes, index is the node number + private val nodeDBbyNodeNum = ConcurrentHashMap() + + // The database of active nodes, index is the node user ID string + // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). + private val nodeDBbyID get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } + + // + // END OF MODEL + // + + private val deviceVersion get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") + private val appVersion get() = BuildConfig.VERSION_CODE + private val minAppVersion get() = myNodeInfo?.minAppVersion ?: 0 + + // Map a nodenum to a node, or throw an exception if not found + private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n) + + /** Map a nodeNum to the nodeId string + If we have a NodeInfo for this ID we prefer to return the string ID inside the user record. + but some nodes might not have a user record at all (because not yet received), in that case, we return + a hex version of the ID just based on the number */ + private fun toNodeID(n: Int): String = + if (n == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) + } + + // given a nodeNum, return a db entry - creating if necessary + private fun getOrCreateNodeInfo(n: Int) = nodeDBbyNodeNum.getOrPut(n) { + val userId = DataPacket.nodeNumToDefaultId(n) + val defaultUser = user { + id = userId + longName = "Meshtastic ${userId.takeLast(n = 4)}" + shortName = userId.takeLast(n = 4) + hwModel = MeshProtos.HardwareModel.UNSET + } + + NodeEntity( + num = n, + user = defaultUser, + longName = defaultUser.longName, + ) + } + + private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() + private val rangeTestRegex = Regex("seq (\\d{1,10})") + + // Map a userid to a node/ node num, or throw an exception if not found + // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find it based on node number + private fun toNodeInfo(id: String): NodeEntity { + // If this is a valid hexaddr will be !null + val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value + + return nodeDBbyID[id] ?: when { + id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) + hexStr != null -> { + val n = hexStr.toLong(16).toInt() + nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) + } + else -> throw InvalidNodeIdException(id) + } + } + + private fun getUserName(num: Int): String = + with(radioConfigRepository.getUser(num)) { "$longName ($shortName)" } + + private val numNodes get() = nodeDBbyNodeNum.size + + /** + * How many nodes are currently online (including our local node) + */ + private val numOnlineNodes get() = nodeDBbyNodeNum.values.count { it.isOnline } + + private fun toNodeNum(id: String): Int = when (id) { + DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST + DataPacket.ID_LOCAL -> myNodeNum + else -> toNodeInfo(id).num + } + + // A helper function that makes it easy to update node info objects + private inline fun updateNodeInfo( + nodeNum: Int, + withBroadcast: Boolean = true, + crossinline updateFn: (NodeEntity) -> Unit, + ) { + val info = getOrCreateNodeInfo(nodeNum) + updateFn(info) + + if (info.user.id.isNotEmpty() && haveNodeDB) { + serviceScope.handledLaunch { + radioConfigRepository.upsert(info) + } + } + + if (withBroadcast) { + serviceBroadcasts.broadcastNodeChange(info.toNodeInfo()) + } + } + + // My node num + private val myNodeNum + get() = myNodeInfo?.myNodeNum + ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") + + // My node ID string + private val myNodeID get() = toNodeID(myNodeNum) + + // Admin channel index + private val MeshPacket.Builder.adminChannelIndex: Int + get() = when { + myNodeNum == to -> 0 + nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> + DataPacket.PKC_CHANNEL_INDEX + + else -> channelSet.settingsList + .indexOfFirst { it.name.equals("admin", ignoreCase = true) } + .coerceAtLeast(0) + } + + // Generate a new mesh packet builder with our node as the sender, and the specified node num + private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { + if (myNodeInfo == null) { + throw RadioNotConnectedException() + } + + from = 0 // don't add myNodeNum + + to = idNum + } + + /** + * Generate a new mesh packet builder with our node as the sender, and the specified recipient + * + * If id is null we assume a broadcast message + */ + private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) + + /** + * Helper to make it easy to build a subpacket in the proper protobufs + */ + private fun MeshPacket.Builder.buildMeshPacket( + wantAck: Boolean = false, + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + hopLimit: Int = localConfig.lora.hopLimit, + channel: Int = 0, + priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, + initFn: MeshProtos.Data.Builder.() -> Unit + ): MeshPacket { + this.wantAck = wantAck + this.id = id + this.hopLimit = hopLimit + this.priority = priority + decoded = MeshProtos.Data.newBuilder().also { + initFn(it) + }.build() + if (channel == DataPacket.PKC_CHANNEL_INDEX) { + pkiEncrypted = true + nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> + this.publicKey = publicKey + } + } else { + this.channel = channel + } + + return build() + } + + /** + * Helper to make it easy to build a subpacket in the proper protobufs + */ + private fun MeshPacket.Builder.buildAdminPacket( + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one + wantResponse: Boolean = false, + initFn: AdminProtos.AdminMessage.Builder.() -> Unit + ): MeshPacket = buildMeshPacket( + id = id, + wantAck = true, + channel = adminChannelIndex, + priority = MeshPacket.Priority.RELIABLE + ) { + this.wantResponse = wantResponse + portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + payload = AdminProtos.AdminMessage.newBuilder().also { + initFn(it) + it.sessionPasskey = sessionPasskey + }.build().toByteString() + } + + // Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so + private fun toDataPacket(packet: MeshPacket): DataPacket? { + return if (!packet.hasDecoded()) { + // We never convert packets that are not DataPackets + null + } else { + val data = packet.decoded + + DataPacket( + from = toNodeID(packet.from), + to = toNodeID(packet.to), + time = packet.rxTime * 1000L, + id = packet.id, + dataType = data.portnumValue, + bytes = data.payload.toByteArray(), + hopLimit = packet.hopLimit, + channel = if (packet.pkiEncrypted) DataPacket.PKC_CHANNEL_INDEX else packet.channel, + ) + } + } + + private fun toMeshPacket(p: DataPacket): MeshPacket { + return newMeshPacketTo(p.to!!).buildMeshPacket( + id = p.id, + wantAck = true, + hopLimit = p.hopLimit, + channel = p.channel, + ) { + portnumValue = p.dataType + payload = ByteString.copyFrom(p.bytes) + } + } + + private val rememberDataType = setOf( + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + Portnums.PortNum.WAYPOINT_APP_VALUE, + ) + + private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { + if (dataPacket.dataType !in rememberDataType) return + val fromLocal = dataPacket.from == DataPacket.ID_LOCAL + val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST + val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from + + // contactKey: unique contact key filter (channel)+(nodeId) + val contactKey = "${dataPacket.channel}$contactId" + + val packetToSave = Packet( + uuid = 0L, // autoGenerated + myNodeNum = myNodeNum, + packetId = dataPacket.id, + port_num = dataPacket.dataType, + contact_key = contactKey, + received_time = System.currentTimeMillis(), + read = fromLocal, + data = dataPacket + ) + serviceScope.handledLaunch { + packetRepository.get().apply { + insert(packetToSave) + val isMuted = getContactSettings(contactKey).isMuted + if (updateNotification && !isMuted) updateMessageNotification(contactKey, dataPacket) + } + } + } + + // Update our model and resend as needed for a MeshPacket we just received from the radio + private fun handleReceivedData(packet: MeshPacket) { + myNodeInfo?.let { myInfo -> + val data = packet.decoded + val bytes = data.payload.toByteArray() + val fromId = toNodeID(packet.from) + val dataPacket = toDataPacket(packet) + + if (dataPacket != null) { + + // We ignore most messages that we sent + val fromUs = myInfo.myNodeNum == packet.from + + debug("Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes") + + dataPacket.status = MessageStatus.RECEIVED + + // if (p.hasUser()) handleReceivedUser(fromNum, p.user) + + // We tell other apps about most message types, but some may have sensitive data, so that is not shared' + var shouldBroadcast = !fromUs + + when (data.portnumValue) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { + if (fromUs) return + + // TODO temporary solution to Range Test spam, may be removed in the future + val isRangeTest = rangeTestRegex.matches(data.payload.toStringUtf8()) + if (!moduleConfig.rangeTest.enabled && isRangeTest) return + + debug("Received CLEAR_TEXT from $fromId") + rememberDataPacket(dataPacket) + } + + Portnums.PortNum.WAYPOINT_APP_VALUE -> { + val u = MeshProtos.Waypoint.parseFrom(data.payload) + // Validate locked Waypoints from the original sender + if (u.lockedTo != 0 && u.lockedTo != packet.from) return + rememberDataPacket(dataPacket, u.expire > currentSecond()) + } + + // Handle new style position info + Portnums.PortNum.POSITION_APP_VALUE -> { + val u = MeshProtos.Position.parseFrom(data.payload) + // debug("position_app ${packet.from} ${u.toOneLineString()}") + if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) { + debug("Ignoring nop position update from position request") + } else { + handleReceivedPosition(packet.from, u, dataPacket.time) + } + } + + // Handle new style user info + Portnums.PortNum.NODEINFO_APP_VALUE -> + if (!fromUs) { + val u = MeshProtos.User.parseFrom(data.payload).copy { + if (isLicensed) clearPublicKey() + if (packet.viaMqtt) longName = "$longName (MQTT)" + } + handleReceivedUser(packet.from, u, packet.channel) + } + + // Handle new telemetry info + Portnums.PortNum.TELEMETRY_APP_VALUE -> { + val u = TelemetryProtos.Telemetry.parseFrom(data.payload) + .copy { if (time == 0) time = (dataPacket.time / 1000L).toInt() } + handleReceivedTelemetry(packet.from, u) + } + + Portnums.PortNum.ROUTING_APP_VALUE -> { + // We always send ACKs to other apps, because they might care about the messages they sent + shouldBroadcast = true + val u = MeshProtos.Routing.parseFrom(data.payload) + + if (u.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { + radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle)) + } + + handleAckNak(data.requestId, fromId, u.errorReasonValue) + queueResponse.remove(data.requestId)?.complete(true) + } + + Portnums.PortNum.ADMIN_APP_VALUE -> { + val u = AdminProtos.AdminMessage.parseFrom(data.payload) + handleReceivedAdmin(packet.from, u) + shouldBroadcast = false + } + + Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + val p = PaxcountProtos.Paxcount.parseFrom(data.payload) + handleReceivedPaxcounter(packet.from, p) + shouldBroadcast = false + } + + Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) + handleReceivedStoreAndForward(dataPacket, u) + shouldBroadcast = false + } + + Portnums.PortNum.RANGE_TEST_APP_VALUE -> { + if (!moduleConfig.rangeTest.enabled) return + val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u) + } + + Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> { + val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u) + } + + Portnums.PortNum.TRACEROUTE_APP_VALUE -> { + radioConfigRepository.setTracerouteResponse( + packet.getTracerouteResponse(::getUserName) + ) + } + + else -> debug("No custom processing needed for ${data.portnumValue}") + } + + // We always tell other apps when new data packets arrive + if (shouldBroadcast) { + serviceBroadcasts.broadcastReceivedData(dataPacket) + } + + GeeksvilleApplication.analytics.track( + "num_data_receive", + DataPair(1) + ) + + GeeksvilleApplication.analytics.track( + "data_receive", + DataPair("num_bytes", bytes.size), + DataPair("type", data.portnumValue) + ) + } + } + } + + private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { + if (fromNodeNum == myNodeNum) { + when (a.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + val response = a.getConfigResponse + debug("Admin: received config ${response.payloadVariantCase}") + setLocalConfig(response) + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + val mi = myNodeInfo + if (mi != null) { + val ch = a.getChannelResponse + debug("Admin: Received channel ${ch.index}") + + if (ch.index + 1 < mi.maxChannels) { + handleChannel(ch) + } + } + } + else -> warn("No special processing needed for ${a.payloadVariantCase}") + } + } else { + debug("Admin: Received session_passkey from $fromNodeNum") + sessionPasskey = a.sessionPasskey + } + } + + // Update our DB of users based on someone sending out a User subpacket + private fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0) { + updateNodeInfo(fromNum) { + val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) + + val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey + it.user = if (keyMatch) p else p.copy { + warn("Public key mismatch from $longName ($shortName)") + publicKey = it.errorByteString + } + it.longName = p.longName + it.shortName = p.shortName + it.channel = channel + if (newNode) { + serviceNotifications.showNewNodeSeenNotification(it) + } + } + } + + /** Update our DB of users based on someone sending out a Position subpacket + * @param defaultTime in msecs since 1970 + */ + private fun handleReceivedPosition( + fromNum: Int, + p: MeshProtos.Position, + defaultTime: Long = System.currentTimeMillis() + ) { + // Nodes periodically send out position updates, but those updates might not contain a lat & lon (because no GPS lock) + // We like to look at the local node to see if it has been sending out valid lat/lon, so for the LOCAL node (only) + // we don't record these nop position updates + if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { + debug("Ignoring nop position update for the local node") + } else { + updateNodeInfo(fromNum) { + debug("update position: ${it.longName?.toPIIString()} with ${p.toPIIString()}") + it.setPosition(p, (defaultTime / 1000L).toInt()) + } + } + } + + // Update our DB of users based on someone sending out a Telemetry subpacket + private fun handleReceivedTelemetry( + fromNum: Int, + t: TelemetryProtos.Telemetry, + ) { + if (t.hasLocalStats()) { + localStatsTelemetry = t + maybeUpdateServiceStatusNotification() + } + updateNodeInfo(fromNum) { + when { + t.hasDeviceMetrics() -> it.deviceTelemetry = t + t.hasEnvironmentMetrics() -> it.environmentTelemetry = t + t.hasPowerMetrics() -> it.powerTelemetry = t + } + } + } + + private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { + updateNodeInfo(fromNum) { it.paxcounter = p } + } + + private fun handleReceivedStoreAndForward( + dataPacket: DataPacket, + s: StoreAndForwardProtos.StoreAndForward, + ) { + debug("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") + when (s.variantCase) { + StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { + val u = dataPacket.copy( + bytes = s.stats.toString().encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE + ) + rememberDataPacket(u) + } + + StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { + val text = """ + Total messages: ${s.history.historyMessages} + History window: ${s.history.window / 60000} min + Last request: ${s.history.lastRequest} + """.trimIndent() + val u = dataPacket.copy( + bytes = text.encodeToByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE + ) + rememberDataPacket(u) + } + + StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { + if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { + dataPacket.to = DataPacket.ID_BROADCAST + } + val u = dataPacket.copy( + bytes = s.text.toByteArray(), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + ) + rememberDataPacket(u) + } + + else -> {} + } + } + + // If apps try to send packets when our radio is sleeping, we queue them here instead + private val offlineSentPackets = mutableListOf() + + // Update our model and resend as needed for a MeshPacket we just received from the radio + private fun handleReceivedMeshPacket(packet: MeshPacket) { + if (haveNodeDB) { + processReceivedMeshPacket(packet.toBuilder().apply { + // If the rxTime was not set by the device, update with current time + if (packet.rxTime == 0) setRxTime(currentSecond()) + }.build()) + onNodeDBChanged() + } else { + warn("Ignoring early received packet: ${packet.toOneLineString()}") + // earlyReceivedPackets.add(packet) + // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, but if the device is messed up it might try to send forever + } + } + + private val queuedPackets = ConcurrentLinkedQueue() + private val queueResponse = mutableMapOf>() + private var queueJob: Job? = null + + private fun sendPacket(packet: MeshPacket): CompletableFuture { + // send the packet to the radio and return a CompletableFuture that will be completed with the result + val future = CompletableFuture() + queueResponse[packet.id] = future + try { + if (connectionState != ConnectionState.CONNECTED) throw RadioNotConnectedException() + sendToRadio(ToRadio.newBuilder().apply { + this.packet = packet + }) + } catch (ex: Exception) { + errormsg("sendToRadio error:", ex) + future.complete(false) + } + return future + } + + private fun startPacketQueue() { + if (queueJob?.isActive == true) return + queueJob = serviceScope.handledLaunch { + debug("packet queueJob started") + while (connectionState == ConnectionState.CONNECTED) { + // take the first packet from the queue head + val packet = queuedPackets.poll() ?: break + try { + // send packet to the radio and wait for response + val response = sendPacket(packet) + debug("queueJob packet id=${packet.id.toUInt()} waiting") + val success = response.get(2, TimeUnit.MINUTES) + debug("queueJob packet id=${packet.id.toUInt()} success $success") + } catch (e: TimeoutException) { + debug("queueJob packet id=${packet.id.toUInt()} timeout") + } catch (e: Exception) { + debug("queueJob packet id=${packet.id.toUInt()} failed") + } + } + } + } + + private fun stopPacketQueue() { + if (queueJob?.isActive == true) { + info("Stopping packet queueJob") + queueJob?.cancel() + queueJob = null + queuedPackets.clear() + queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(false) + queueResponse.clear() + } + } + + private fun sendNow(p: DataPacket) { + val packet = toMeshPacket(p) + p.time = System.currentTimeMillis() // update time to the actual time we started sending + // debug("Sending to radio: ${packet.toPIIString()}") + sendToRadio(packet) + } + + private fun processQueuedPackets() { + val sentPackets = mutableListOf() + offlineSentPackets.forEach { p -> + try { + sendNow(p) + sentPackets.add(p) + } catch (ex: Exception) { + errormsg("Error sending queued message:", ex) + } + } + offlineSentPackets.removeAll(sentPackets) + } + + private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000) { + var dataPacket: DataPacket? = null + while (dataPacket == null) { + dataPacket = packetRepository.get().getPacketById(packetId)?.data + if (dataPacket == null) delay(100) + } + dataPacket + } + + /** + * Change the status on a DataPacket and update watchers + */ + private fun changeStatus(packetId: Int, m: MessageStatus) = serviceScope.handledLaunch { + if (packetId != 0) getDataPacketById(packetId)?.let { p -> + if (p.status == m) return@handledLaunch + packetRepository.get().updateMessageStatus(p, m) + serviceBroadcasts.broadcastMessageStatus(packetId, m) + } + } + + /** + * Handle an ack/nak packet by updating sent message status + */ + private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) { + serviceScope.handledLaunch { + val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE + val p = packetRepository.get().getPacketById(requestId) + // distinguish real ACKs coming from the intended receiver + val m = when { + isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED + isAck -> MessageStatus.DELIVERED + else -> MessageStatus.ERROR + } + if (p != null && p.data.status != MessageStatus.RECEIVED) { + p.data.status = m + p.routingError = routingError + packetRepository.get().update(p) + } + serviceBroadcasts.broadcastMessageStatus(requestId, m) + } + } + + // Update our model and resend as needed for a MeshPacket we just received from the radio + private fun processReceivedMeshPacket(packet: MeshPacket) { + val fromNum = packet.from + + // FIXME, perhaps we could learn our node ID by looking at any to packets the radio + // decided to pass through to us (except for broadcast packets) + // val toNum = packet.to + + // debug("Received: $packet") + if (packet.hasDecoded()) { + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = packet.from, + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ) + insertMeshLog(packetToSave) + + serviceScope.handledLaunch { + radioConfigRepository.emitMeshPacket(packet) + } + + // Update last seen for the node that sent the packet, but also for _our node_ because anytime a packet passes + // through our node on the way to the phone that means that local node is also alive in the mesh + + val isOtherNode = myNodeNum != fromNum + updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { + it.lastHeard = currentSecond() + } + + // Do not generate redundant broadcasts of node change for this bookkeeping updateNodeInfo call + // because apps really only care about important updates of node state - which handledReceivedData will give them + updateNodeInfo(fromNum, withBroadcast = false) { + // Update our last seen based on any valid timestamps. If the device didn't provide a timestamp make one + it.lastHeard = packet.rxTime + it.snr = packet.rxSnr + it.rssi = packet.rxRssi + + // Generate our own hopsAway, comparing hopStart to hopLimit. + it.hopsAway = if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + -1 + } else { + packet.hopStart - packet.hopLimit + } + } + handleReceivedData(packet) + } + } + + private fun insertMeshLog(packetToSave: MeshLog) { + serviceScope.handledLaunch { + // Do not log, because might contain PII + // info("insert: ${packetToSave.message_type} = ${packetToSave.raw_message.toOneLineString()}") + meshLogRepository.get().insert(packetToSave) + } + } + + private fun setLocalConfig(config: ConfigProtos.Config) { + serviceScope.handledLaunch { + radioConfigRepository.setLocalConfig(config) + } + } + + private fun setLocalModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + serviceScope.handledLaunch { + radioConfigRepository.setLocalModuleConfig(config) + } + } + + private fun clearLocalConfig() { + serviceScope.handledLaunch { + radioConfigRepository.clearLocalConfig() + radioConfigRepository.clearLocalModuleConfig() + } + } + + private fun updateChannelSettings(ch: ChannelProtos.Channel) = serviceScope.handledLaunch { + radioConfigRepository.updateChannelSettings(ch) + } + + private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() + + // If we just changed our nodedb, we might want to do somethings + private fun onNodeDBChanged() { + maybeUpdateServiceStatusNotification() + } + + /** + * Send in analytics about mesh connection + */ + private fun reportConnection() { + val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown") + GeeksvilleApplication.analytics.track( + "mesh_connect", + DataPair("num_nodes", numNodes), + DataPair("num_online", numOnlineNodes), + radioModel + ) + + // Once someone connects to hardware start tracking the approximate number of nodes in their mesh + // this allows us to collect stats on what typical mesh size is and to tell difference between users who just + // downloaded the app, vs has connected it to some hardware. + GeeksvilleApplication.analytics.setUserInfo( + DataPair("num_nodes", numNodes), + radioModel + ) + } + + private var sleepTimeout: Job? = null + + // msecs since 1970 we started this connection + private var connectTimeMsec = 0L + + // Called when we gain/lose connection to our radio + private fun onConnectionChanged(c: ConnectionState) { + debug("onConnectionChanged: $connectionState -> $c") + + // Perform all the steps needed once we start waiting for device sleep to complete + fun startDeviceSleep() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + if (connectTimeMsec != 0L) { + val now = System.currentTimeMillis() + connectTimeMsec = 0L + + GeeksvilleApplication.analytics.track( + "connected_seconds", + DataPair((now - connectTimeMsec) / 1000.0) + ) + } + + // Have our timeout fire in the appropriate number of seconds + sleepTimeout = serviceScope.handledLaunch { + try { + // If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds + val timeout = (localConfig.power?.lsSecs ?: 0) + 30 + + debug("Waiting for sleeping device, timeout=$timeout secs") + delay(timeout * 1000L) + warn("Device timeout out, setting disconnected") + onConnectionChanged(ConnectionState.DISCONNECTED) + } catch (ex: CancellationException) { + debug("device sleep timeout cancelled") + } + } + + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } + + fun startDisconnect() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + GeeksvilleApplication.analytics.track( + "mesh_disconnect", + DataPair("num_nodes", numNodes), + DataPair("num_online", numOnlineNodes) + ) + GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) + + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } + + fun startConnect() { + // Do our startup init + try { + connectTimeMsec = System.currentTimeMillis() + startConfig() + } catch (ex: InvalidProtocolBufferException) { + errormsg( + "Invalid protocol buffer sent by device - update device software and try again", + ex + ) + } catch (ex: RadioNotConnectedException) { + // note: no need to call startDeviceSleep(), because this exception could only have reached us if it was already called + errormsg("Lost connection to radio during init - waiting for reconnect") + } catch (ex: RemoteException) { + // It seems that when the ESP32 goes offline it can briefly come back for a 100ms ish which + // causes the phone to try and reconnect. If we fail downloading our initial radio state we don't want to + // claim we have a valid connection still + connectionState = ConnectionState.DEVICE_SLEEP + startDeviceSleep() + throw ex // Important to rethrow so that we don't tell the app all is well + } + } + + // Cancel any existing timeouts + sleepTimeout?.let { + it.cancel() + sleepTimeout = null + } + + connectionState = c + when (c) { + ConnectionState.CONNECTED -> startConnect() + ConnectionState.DEVICE_SLEEP -> startDeviceSleep() + ConnectionState.DISCONNECTED -> startDisconnect() + } + + // Update the android notification in the status bar + maybeUpdateServiceStatusNotification() + } + + private fun maybeUpdateServiceStatusNotification() { + var update = false + val currentSummary = notificationSummary + val currentStats = localStats + val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis + if ( + !currentSummary.isNullOrBlank() && + (previousSummary == null || !previousSummary.equals(currentSummary)) + ) { + previousSummary = currentSummary + update = true + } + if ( + currentStats != null && + (previousStats == null || !(previousStats?.equals(currentStats) ?: false)) + ) { + previousStats = currentStats + update = true + } + if (update) { + serviceNotifications.updateServiceStateNotification( + summaryString = currentSummary, + localStats = currentStats, + currentStatsUpdatedAtMillis = currentStatsUpdatedAtMillis + ) + } + } + + private fun onRadioConnectionState(state: RadioServiceConnectionState) { + // sleep now disabled by default on ESP32, permanent is true unless light sleep enabled + val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER + val lsEnabled = localConfig.power.isPowerSaving || isRouter + val connected = state.isConnected + val permanent = state.isPermanent || !lsEnabled + onConnectionChanged( + when { + connected -> ConnectionState.CONNECTED + permanent -> ConnectionState.DISCONNECTED + else -> ConnectionState.DEVICE_SLEEP + } + ) + } + + private fun onReceiveFromRadio(bytes: ByteArray) { + try { + val proto = MeshProtos.FromRadio.parseFrom(bytes) + // info("Received from radio service: ${proto.toOneLineString()}") + when (proto.payloadVariantCase.number) { + MeshProtos.FromRadio.PACKET_FIELD_NUMBER -> handleReceivedMeshPacket(proto.packet) + MeshProtos.FromRadio.CONFIG_COMPLETE_ID_FIELD_NUMBER -> handleConfigComplete(proto.configCompleteId) + MeshProtos.FromRadio.MY_INFO_FIELD_NUMBER -> handleMyInfo(proto.myInfo) + MeshProtos.FromRadio.NODE_INFO_FIELD_NUMBER -> handleNodeInfo(proto.nodeInfo) + MeshProtos.FromRadio.CHANNEL_FIELD_NUMBER -> handleChannel(proto.channel) + MeshProtos.FromRadio.CONFIG_FIELD_NUMBER -> handleDeviceConfig(proto.config) + MeshProtos.FromRadio.MODULECONFIG_FIELD_NUMBER -> handleModuleConfig(proto.moduleConfig) + MeshProtos.FromRadio.QUEUESTATUS_FIELD_NUMBER -> handleQueueStatus(proto.queueStatus) + MeshProtos.FromRadio.METADATA_FIELD_NUMBER -> handleMetadata(proto.metadata) + MeshProtos.FromRadio.MQTTCLIENTPROXYMESSAGE_FIELD_NUMBER -> handleMqttProxyMessage(proto.mqttClientProxyMessage) + MeshProtos.FromRadio.CLIENTNOTIFICATION_FIELD_NUMBER -> { + handleClientNotification(proto.clientNotification) + } + else -> errormsg("Unexpected FromRadio variant") + } + } catch (ex: InvalidProtocolBufferException) { + errormsg("Invalid Protobuf from radio, len=${bytes.size}", ex) + } + } + + // A provisional MyNodeInfo that we will install if all of our node config downloads go okay + private var newMyNodeInfo: MyNodeEntity? = null + + // provisional NodeInfos we will install if all goes well + private val newNodes = mutableListOf() + + // Used to make sure we never get foold by old BLE packets + private var configNonce = 1 + + private fun handleDeviceConfig(config: ConfigProtos.Config) { + debug("Received config ${config.toOneLineString()}") + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Config ${config.payloadVariantCase}", + received_date = System.currentTimeMillis(), + raw_message = config.toString(), + fromRadio = fromRadio { this.config = config }, + ) + insertMeshLog(packetToSave) + setLocalConfig(config) + val configCount = localConfig.allFields.size + radioConfigRepository.setStatusMessage("Device config ($configCount / $configTotal)") + } + + private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + debug("Received moduleConfig ${config.toOneLineString()}") + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "ModuleConfig ${config.payloadVariantCase}", + received_date = System.currentTimeMillis(), + raw_message = config.toString(), + fromRadio = fromRadio { moduleConfig = config }, + ) + insertMeshLog(packetToSave) + setLocalModuleConfig(config) + val moduleCount = moduleConfig.allFields.size + radioConfigRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") + } + + private fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { + debug("queueStatus ${queueStatus.toOneLineString()}") + val (success, isFull, requestId) = with(queueStatus) { + Triple(res == 0, free == 0, meshPacketId) + } + if (success && isFull) return // Queue is full, wait for free != 0 + if (requestId != 0) { + queueResponse.remove(requestId)?.complete(success) + } else { + queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(success) + } + } + + private fun handleChannel(ch: ChannelProtos.Channel) { + debug("Received channel ${ch.index}") + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Channel", + received_date = System.currentTimeMillis(), + raw_message = ch.toString(), + fromRadio = fromRadio { channel = ch }, + ) + insertMeshLog(packetToSave) + if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch) + val maxChannels = myNodeInfo?.maxChannels ?: 8 + radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") + } + + /** + * Convert a protobuf NodeInfo into our model objects and update our node DB + */ + private fun installNodeInfo(info: MeshProtos.NodeInfo) { + // Just replace/add any entry + updateNodeInfo(info.num) { + if (info.hasUser()) { + it.user = info.user.copy { + if (isLicensed) clearPublicKey() + if (info.viaMqtt) longName = "$longName (MQTT)" + } + it.longName = info.user.longName + it.shortName = info.user.shortName + } + + if (info.hasPosition()) { + it.position = info.position + it.latitude = Position.degD(info.position.latitudeI) + it.longitude = Position.degD(info.position.longitudeI) + } + + it.lastHeard = info.lastHeard + + if (info.hasDeviceMetrics()) { + it.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } + } + + it.channel = info.channel + it.viaMqtt = info.viaMqtt + + // hopsAway should be nullable/optional from the proto, but explicitly checking it's existence first + it.hopsAway = if (info.hasHopsAway()) { + info.hopsAway + } else { + -1 + } + it.isFavorite = info.isFavorite + } + } + + private fun handleNodeInfo(info: MeshProtos.NodeInfo) { + debug("Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}") + + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "NodeInfo", + received_date = System.currentTimeMillis(), + raw_message = info.toString(), + fromRadio = fromRadio { nodeInfo = info }, + ) + insertMeshLog(packetToSave) + + newNodes.add(info) + radioConfigRepository.setStatusMessage("Nodes (${newNodes.size} / 100)") + } + + private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null + private var rawDeviceMetadata: MeshProtos.DeviceMetadata? = null + + /** Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device + * and again after we have the node DB (which might allow us a better notion of our HwModel. + */ + private fun regenMyNodeInfo() { + val myInfo = rawMyNodeInfo + if (myInfo != null) { + val mi = with(myInfo) { + MyNodeEntity( + myNodeNum = myNodeNum, + model = when (val hwModel = rawDeviceMetadata?.hwModel) { + null, MeshProtos.HardwareModel.UNSET -> null + else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() + }, + firmwareVersion = rawDeviceMetadata?.firmwareVersion, + couldUpdate = false, + shouldUpdate = false, // TODO add check after re-implementing firmware updates + currentPacketId = currentPacketId and 0xffffffffL, + messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code + minAppVersion = minAppVersion, + maxChannels = 8, + hasWifi = rawDeviceMetadata?.hasWifi ?: false, + ) + } + newMyNodeInfo = mi + } + } + + private fun sendAnalytics() { + val myInfo = rawMyNodeInfo + val mi = myNodeInfo + if (myInfo != null && mi != null) { + // Track types of devices and firmware versions in use + GeeksvilleApplication.analytics.setUserInfo( + DataPair("firmware", mi.firmwareVersion), + DataPair("hw_model", mi.model), + ) + } + } + + /** + * Update MyNodeInfo (called from either new API version or the old one) + */ + private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "MyNodeInfo", + received_date = System.currentTimeMillis(), + raw_message = myInfo.toString(), + fromRadio = fromRadio { this.myInfo = myInfo }, + ) + insertMeshLog(packetToSave) + + rawMyNodeInfo = myInfo + + // We'll need to get a new set of channels and settings now + serviceScope.handledLaunch { + radioConfigRepository.clearChannelSet() + radioConfigRepository.clearLocalConfig() + radioConfigRepository.clearLocalModuleConfig() + } + } + + /** + * Update our DeviceMetadata + */ + private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { + debug("Received deviceMetadata ${metadata.toOneLineString()}") + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "DeviceMetadata", + received_date = System.currentTimeMillis(), + raw_message = metadata.toString(), + fromRadio = fromRadio { this.metadata = metadata }, + ) + insertMeshLog(packetToSave) + + rawDeviceMetadata = metadata + regenMyNodeInfo() + } + + /** + * Publish MqttClientProxyMessage (fromRadio) + */ + private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { + with(message) { + when (payloadVariantCase) { + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { + mqttRepository.publish(topic, text.encodeToByteArray(), retained) + } + + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { + mqttRepository.publish(topic, data.toByteArray(), retained) + } + + else -> {} + } + } + } + + private fun handleClientNotification(notification: MeshProtos.ClientNotification) { + debug("Received clientNotification ${notification.toOneLineString()}") + radioConfigRepository.setErrorMessage(notification.message) + // if the future for the originating request is still in the queue, complete as unsuccessful for now + queueResponse.remove(notification.replyId)?.complete(false) + } + + /** + * Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) + */ + private fun startMqttClientProxy() { + if (mqttMessageFlow?.isActive == true) return + if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) { + mqttMessageFlow = mqttRepository.proxyMessageFlow.onEach { message -> + sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) + }.catch { throwable -> + radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") + }.launchIn(serviceScope) + } + } + + private fun stopMqttClientProxy() { + if (mqttMessageFlow?.isActive == true) { + info("Stopping MqttClientProxy") + mqttMessageFlow?.cancel() + mqttMessageFlow = null + } + } + + // If we've received our initial config, our radio settings and all of our channels, send any queued packets and broadcast connected to clients + private fun onHasSettings() { + + processQueuedPackets() // send any packets that were queued up + startMqttClientProxy() + + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + onNodeDBChanged() + reportConnection() + } + + private fun handleConfigComplete(configCompleteId: Int) { + if (configCompleteId == configNonce) { + + val packetToSave = MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "ConfigComplete", + received_date = System.currentTimeMillis(), + raw_message = configCompleteId.toString(), + fromRadio = fromRadio { this.configCompleteId = configCompleteId }, + ) + insertMeshLog(packetToSave) + + // This was our config request + if (newMyNodeInfo == null || newNodes.isEmpty()) { + errormsg("Did not receive a valid config") + } else { + discardNodeDB() + debug("Installing new node DB") + myNodeInfo = newMyNodeInfo + + newNodes.forEach(::installNodeInfo) + newNodes.clear() // Just to save RAM ;-) + + serviceScope.handledLaunch { + radioConfigRepository.installNodeDB(myNodeInfo!!, nodeDBbyNodeNum.values.toList()) + } + + haveNodeDB = true // we now have nodes from real hardware + + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + setTimeOnly = currentSecond() + }) + sendAnalytics() + + if (deviceVersion < minDeviceVersion || appVersion < minAppVersion) { + info("Device firmware or app is too old, faking config so firmware update can occur") + setLocalConfig(config { + security = localConfig.security.copy { isManaged = true } + }) + } + onHasSettings() + } + } else { + warn("Ignoring stale config complete") + } + } + + /** + * Start the modern (REV2) API configuration flow + */ + private fun startConfig() { + configNonce += 1 + newNodes.clear() + newMyNodeInfo = null + + debug("Starting config nonce=$configNonce") + + sendToRadio(ToRadio.newBuilder().apply { + this.wantConfigId = configNonce + }) + } + + /** + * Send a position (typically from our built in GPS) into the mesh. + */ + private fun sendPosition( + position: MeshProtos.Position, + destNum: Int? = null, + wantResponse: Boolean = false + ) { + try { + val mi = myNodeInfo + if (mi != null) { + val idNum = destNum ?: mi.myNodeNum // when null we just send to the local node + debug("Sending our position/time to=$idNum ${Position(position)}") + + // Also update our own map for our nodeNum, by handling the packet just like packets from other users + if (!localConfig.position.fixedPosition) { + handleReceivedPosition(mi.myNodeNum, position) + } + + sendToRadio(newMeshPacketTo(idNum).buildMeshPacket( + channel = if (destNum == null) 0 else nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = position.toByteString() + this.wantResponse = wantResponse + }) + } + } catch (ex: BLEException) { + warn("Ignoring disconnected radio during gps location update") + } + } + + /** + * Send setOwner admin packet with [MeshProtos.User] protobuf + */ + private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { + val dest = nodeDBbyID[id] + ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen + val old = dest.user + if (longName == old.longName && shortName == old.shortName && isLicensed == old.isLicensed) { + debug("Ignoring nop owner change") + } else { + debug("setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed") + + // Also update our own map for our nodeNum, by handling the packet just like packets from other users + handleReceivedUser(dest.num, user) + + // encapsulate our payload in the proper protobuf and fire it off + sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { + setOwner = user + }) + } + } + + // Do not use directly, instead call generatePacketId() + private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue + + /** + * Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) + */ + @Synchronized + private fun generatePacketId(): Int { + val numPacketIds = + ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint + + currentPacketId++ + + currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits + + // Use modulus and +1 to ensure we skip 0 on any values we return + return ((currentPacketId % numPacketIds) + 1L).toInt() + } + + private fun enqueueForSending(p: DataPacket) { + if (p.dataType in rememberDataType) { + offlineSentPackets.add(p) + } + } + + private val binder = object : IMeshService.Stub() { + + override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { + debug("Passing through device change to radio service: ${deviceAddr.anonymize}") + + val res = radioInterfaceService.setDeviceAddress(deviceAddr) + if (res) { + discardNodeDB() + } else { + serviceBroadcasts.broadcastConnection() + } + res + } + + // Note: bound methods don't get properly exception caught/logged, so do that with a wrapper + // per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63 + override fun subscribeReceiver(packageName: String, receiverName: String) = + toRemoteExceptions { + clientPackages[receiverName] = packageName + } + + override fun getUpdateStatus(): Int = -4 // ProgressNotStarted + + override fun startFirmwareUpdate() = toRemoteExceptions { + // TODO reimplement this after we have a new firmware update mechanism + } + + override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() + + override fun getMyId() = toRemoteExceptions { myNodeID } + + override fun getPacketId() = toRemoteExceptions { generatePacketId() } + + override fun setOwner(user: MeshUser) = toRemoteExceptions { + setOwner(generatePacketId(), user { + id = user.id + longName = user.longName + shortName = user.shortName + isLicensed = user.isLicensed + }) + } + + override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions { + val parsed = MeshProtos.User.parseFrom(payload) + setOwner(id, parsed) + } + + override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getOwnerRequest = true + }) + } + + override fun send(p: DataPacket) { + toRemoteExceptions { + if (p.id == 0) p.id = generatePacketId() + + info("sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes (connectionState=$connectionState)") + + if (p.dataType == 0) { + throw Exception("Port numbers must be non-zero!") // we are now more strict + } + + if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { + p.status = MessageStatus.ERROR + throw RemoteException("Message too long") + } else { + p.status = MessageStatus.QUEUED + } + + if (connectionState == ConnectionState.CONNECTED) try { + sendNow(p) + } catch (ex: Exception) { + errormsg("Error sending message, so enqueueing", ex) + enqueueForSending(p) + } else { + enqueueForSending(p) + } + serviceBroadcasts.broadcastMessageStatus(p) + + // Keep a record of DataPackets, so GUIs can show proper chat history + rememberDataPacket(p, false) + + GeeksvilleApplication.analytics.track( + "data_send", + DataPair("num_bytes", p.bytes.size), + DataPair("type", p.dataType) + ) + + GeeksvilleApplication.analytics.track( + "num_data_sent", + DataPair(1) + ) + } + } + + override fun getConfig(): ByteArray = toRemoteExceptions { + this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() + } + + /** Send our current radio config to the device + */ + override fun setConfig(payload: ByteArray) = toRemoteExceptions { + setRemoteConfig(generatePacketId(), myNodeNum, payload) + } + + override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new radio config!") + val config = ConfigProtos.Config.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) + if (num == myNodeNum) setLocalConfig(config) // Update our local copy + } + + override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { + getDeviceMetadataRequest = true + } else { + getConfigRequestValue = config + } + }) + } + + /** Send our current module config to the device + */ + override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new module config!") + val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) + if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy + } + + override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getModuleConfigRequestValue = config + }) + } + + override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + setRingtoneMessage = ringtone + }) + } + + override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getRingtoneRequest = true + }) + } + + override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + setCannedMessageModuleMessages = messages + }) + } + + override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getCannedMessageModuleMessagesRequest = true + }) + } + + override fun setChannel(payload: ByteArray?) = toRemoteExceptions { + setRemoteChannel(generatePacketId(), myNodeNum, payload) + } + + override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { + val channel = ChannelProtos.Channel.parseFrom(payload) + sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) + } + + override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getChannelRequest = index + 1 + }) + } + + override fun beginEditSettings() = toRemoteExceptions { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + beginEditSettings = true + }) + } + + override fun commitEditSettings() = toRemoteExceptions { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + commitEditSettings = true + }) + } + + override fun getChannelSet(): ByteArray = toRemoteExceptions { + this@MeshService.channelSet.toByteArray() + } + + override fun getNodes(): MutableList = toRemoteExceptions { + val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() + info("in getOnline, count=${r.size}") + // return arrayOf("+16508675309") + r + } + + override fun connectionState(): String = toRemoteExceptions { + val r = this@MeshService.connectionState + info("in connectionState=$r") + r.toString() + } + + override fun startProvideLocation() = toRemoteExceptions { + startLocationRequests() + } + + override fun stopProvideLocation() = toRemoteExceptions { + stopLocationRequests() + } + + override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { + nodeDBbyNodeNum.remove(nodeNum) + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + removeByNodenum = nodeNum + }) + } + override fun requestUserInfo(destNum: Int) = toRemoteExceptions { + if (destNum != myNodeNum) { + sendToRadio(newMeshPacketTo(destNum + ).buildMeshPacket( + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0 + ) { + portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE + wantResponse = true + payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() + }) + } + } + override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildMeshPacket( + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + wantResponse = true + }) + } + + override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { + val pos = position { + latitudeI = Position.degI(position.latitude) + longitudeI = Position.degI(position.longitude) + altitude = position.altitude + } + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + if (position != Position(0.0, 0.0, 0)) { + setFixedPosition = pos + } else { + removeFixedPosition = true + } + }) + updateNodeInfo(destNum) { + it.setPosition(pos, currentSecond()) + } + } + + override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.TRACEROUTE_APP_VALUE + wantResponse = true + }) + } + + override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { + shutdownSeconds = 5 + }) + } + + override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { + rebootSeconds = 5 + }) + } + + override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { + factoryResetDevice = 1 + }) + } + + override fun requestNodedbReset(requestId: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { + nodedbReset = 1 + }) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt new file mode 100644 index 000000000..585cfaecf --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -0,0 +1,78 @@ +package com.geeksville.mesh.service + +import android.content.Context +import android.content.Intent +import android.os.Parcelable +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.NodeInfo + +class MeshServiceBroadcasts( + private val context: Context, + private val clientPackages: MutableMap, + private val getConnectionState: () -> MeshService.ConnectionState +) { + /** + * Broadcast some received data + * Payload will be a DataPacket + */ + fun broadcastReceivedData(payload: DataPacket) { + + explicitBroadcast( + Intent(MeshService.actionReceived(payload.dataType)).putExtra( + EXTRA_PAYLOAD, + payload + ) + ) + } + + fun broadcastNodeChange(info: NodeInfo) { + MeshService.debug("Broadcasting node change $info") + val intent = Intent(MeshService.ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info) + explicitBroadcast(intent) + } + + fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status) + + fun broadcastMessageStatus(id: Int, status: MessageStatus?) { + if (id == 0) { + MeshService.debug("Ignoring anonymous packet status") + } else { + // Do not log, contains PII possibly + // MeshService.debug("Broadcasting message status $p") + val intent = Intent(MeshService.ACTION_MESSAGE_STATUS).apply { + putExtra(EXTRA_PACKET_ID, id) + putExtra(EXTRA_STATUS, status as Parcelable) + } + explicitBroadcast(intent) + } + } + + /** + * Broadcast our current connection status + */ + fun broadcastConnection() { + val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra( + EXTRA_CONNECTED, + getConnectionState().toString() + ) + explicitBroadcast(intent) + } + + /** + * See com.geeksville.mesh broadcast intents. + * + * RECEIVED_OPAQUE for data received from other nodes + * NODE_CHANGE for new IDs appearing or disappearing + * ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio + * Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION, + * because it implies we have assembled a valid node db. + */ + private fun explicitBroadcast(intent: Intent) { + context.sendBroadcast(intent) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work + clientPackages.forEach { + intent.setClassName(it.value, it.key) + context.sendBroadcast(intent) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt new file mode 100644 index 000000000..4a6392e7f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -0,0 +1,307 @@ +package com.geeksville.mesh.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import com.geeksville.mesh.MainActivity +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.LocalStats +import com.geeksville.mesh.android.notificationManager +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.util.PendingIntentCompat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Suppress("TooManyFunctions") +class MeshServiceNotifications( + private val context: Context +) { + + companion object { + private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000 + const val OPEN_MESSAGE_ACTION = "com.geeksville.mesh.OPEN_MESSAGE_ACTION" + const val OPEN_MESSAGE_EXTRA_CONTACT_KEY = "com.geeksville.mesh.OPEN_MESSAGE_EXTRA_CONTACT_KEY" + const val OPEN_MESSAGE_EXTRA_CONTACT_NAME = + "com.geeksville.mesh.OPEN_MESSAGE_EXTRA_CONTACT_NAME" + } + + private val notificationManager: NotificationManager get() = context.notificationManager + + // We have two notification channels: one for general service status and another one for messages + val notifyId = 101 + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(): String { + val channelId = "my_service" + val channelName = context.getString(R.string.meshtastic_service_notifications) + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_MIN + ).apply { + lightColor = Color.BLUE + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } + notificationManager.createNotificationChannel(channel) + return channelId + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createMessageNotificationChannel(): String { + val channelId = "my_messages" + val channelName = context.getString(R.string.meshtastic_messages_notifications) + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + lightColor = Color.BLUE + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(true) + setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } + notificationManager.createNotificationChannel(channel) + return channelId + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNewNodeNotificationChannel(): String { + val channelId = "new_nodes" + val channelName = context.getString(R.string.meshtastic_new_nodes_notifications) + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + lightColor = Color.BLUE + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(true) + setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } + notificationManager.createNotificationChannel(channel) + return channelId + } + + private val channelId: String by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } else { + // If earlier version channel ID is not used + // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + "" + } + } + + private val messageChannelId: String by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createMessageNotificationChannel() + } else { + // If earlier version channel ID is not used + // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + "" + } + } + + private val newNodeChannelId: String by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNewNodeNotificationChannel() + } else { + "" + } + } + + private fun formatStatsString(stats: LocalStats?, currentStatsUpdatedAtMillis: Long?): String { + val updatedAt = "Next update at: ${ + currentStatsUpdatedAtMillis?.let { + val date = Date(it + FIFTEEN_MINUTES_IN_MILLIS) // Add 15 minutes in milliseconds + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + dateFormat.format(date) + } ?: "???" + }" + val statsJoined = stats?.allFields?.mapNotNull { (k, v) -> + if (k.name == "num_online_nodes" || k.name == "num_total_nodes") { + return@mapNotNull null + } + "${ + k.name.replace('_', ' ').split(" ") + .joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } + }=$v" + }?.joinToString("\n") ?: "No Local Stats" + return "$updatedAt\n$statsJoined" + } + + fun updateServiceStateNotification( + summaryString: String? = null, + localStats: LocalStats? = null, + currentStatsUpdatedAtMillis: Long? = null, + ) { + val statsString = formatStatsString(localStats, currentStatsUpdatedAtMillis) + notificationManager.notify( + notifyId, + createServiceStateNotification( + name = summaryString.orEmpty(), + message = statsString, + nextUpdateAt = currentStatsUpdatedAtMillis?.plus(FIFTEEN_MINUTES_IN_MILLIS) + ) + ) + } + + fun updateMessageNotification(contactKey: String, name: String, message: String) = + notificationManager.notify( + contactKey.hashCode(), // show unique notifications, + createMessageNotification(contactKey, name, message) + ) + + fun showNewNodeSeenNotification(node: NodeEntity) { + notificationManager.notify( + node.num, // show unique notifications + createNewNodeSeenNotification(node.user.shortName, node.user.longName) + ) + } + + private val openAppIntent: PendingIntent by lazy { + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntentCompat.FLAG_IMMUTABLE + ) + } + + private fun openMessageIntent(contactKey: String, contactName: String): PendingIntent { + val intent = Intent(context, MainActivity::class.java) + intent.action = OPEN_MESSAGE_ACTION + intent.putExtra(OPEN_MESSAGE_EXTRA_CONTACT_KEY, contactKey) + intent.putExtra(OPEN_MESSAGE_EXTRA_CONTACT_NAME, contactName) + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntentCompat.FLAG_IMMUTABLE + ) + return pendingIntent + } + + private fun commonBuilder(channel: String): NotificationCompat.Builder { + val builder = NotificationCompat.Builder(context, channel) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(openAppIntent) + + // Set the notification icon + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { + // If running on really old versions of android (<= 5.1.1) (possibly only cyanogen) we might encounter a bug with setting application specific icons + // so punt and stay with just the bluetooth icon - see https://meshtastic.discourse.group/t/android-1-1-42-ready-for-alpha-testing/2399/3?u=geeksville + builder.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + } else { + builder.setSmallIcon( + // vector form icons don't work reliably on older androids + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + R.drawable.app_icon_novect + } else { + R.drawable.app_icon + } + ) + } + return builder + } + + lateinit var serviceNotificationBuilder: NotificationCompat.Builder + fun createServiceStateNotification( + name: String, + message: String? = null, + nextUpdateAt: Long? = null + ): Notification { + if (!::serviceNotificationBuilder.isInitialized) { + serviceNotificationBuilder = commonBuilder(channelId) + } + with(serviceNotificationBuilder) { + priority = NotificationCompat.PRIORITY_MIN + setCategory(Notification.CATEGORY_SERVICE) + setOngoing(true) + setContentTitle(name) + message?.let { + setContentText(it) + setStyle( + NotificationCompat.BigTextStyle() + .bigText(message), + ) + } + nextUpdateAt?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setWhen(it) + setUsesChronometer(true) + setChronometerCountDown(true) + } + } ?: { + setWhen(System.currentTimeMillis()) + } + setShowWhen(true) + } + return serviceNotificationBuilder.build() + } + + lateinit var messageNotificationBuilder: NotificationCompat.Builder + private fun createMessageNotification(contactKey: String, name: String, message: String): Notification { + if (!::messageNotificationBuilder.isInitialized) { + messageNotificationBuilder = commonBuilder(messageChannelId) + } + val person = Person.Builder().setName(name).build() + with(messageNotificationBuilder) { + setContentIntent(openMessageIntent(contactKey, name)) + priority = NotificationCompat.PRIORITY_DEFAULT + setCategory(Notification.CATEGORY_MESSAGE) + setAutoCancel(true) + setStyle( + NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person) + ) + } + return messageNotificationBuilder.build() + } + + lateinit var newNodeSeenNotificationBuilder: NotificationCompat.Builder + private fun createNewNodeSeenNotification(name: String, message: String? = null): Notification { + if (!::newNodeSeenNotificationBuilder.isInitialized) { + newNodeSeenNotificationBuilder = commonBuilder(newNodeChannelId) + } + with(newNodeSeenNotificationBuilder) { + priority = NotificationCompat.PRIORITY_DEFAULT + setCategory(Notification.CATEGORY_STATUS) + setAutoCancel(true) + setContentTitle("New Node Seen: $name") + message?.let { + setContentText(it) + setStyle( + NotificationCompat.BigTextStyle() + .bigText(message), + ) + } + setWhen(System.currentTimeMillis()) + setShowWhen(true) + } + return newNodeSeenNotificationBuilder.build() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt new file mode 100644 index 000000000..ce7a2d0a1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt @@ -0,0 +1,76 @@ +package com.geeksville.mesh.service + +import android.app.ForegroundServiceStartNotAllowedException +import android.content.Context +import android.os.Build +import androidx.work.BackoffPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.geeksville.mesh.BuildConfig +import java.util.concurrent.TimeUnit + +/** + * Helper that calls MeshService.startService() + */ +class ServiceStarter( + appContext: Context, + workerParams: WorkerParameters +) : Worker(appContext, workerParams) { + + override fun doWork(): Result = try { + MeshService.startService(this.applicationContext) + + // Indicate whether the task finished successfully with the Result + Result.success() + } catch (ex: Exception) { + MeshService.errormsg("failure starting service, will retry", ex) + Result.retry() + } +} + +/** + * Just after boot the android OS is super busy, so if we call startForegroundService then, our + * thread might be stalled long enough to expose this Google/Samsung bug: + * https://issuetracker.google.com/issues/76112072#comment56 + */ +fun MeshService.Companion.startServiceLater(context: Context) { + // No point in even starting the service if the user doesn't have a device bonded + info("Received boot complete announcement, starting mesh service in two minutes") + val delayRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(2, TimeUnit.MINUTES) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES) + .addTag("startLater") + .build() + + WorkManager.getInstance(context).enqueue(delayRequest) +} + +/// Helper function to start running our service +fun MeshService.Companion.startService(context: Context) { + // Bind to our service using the same mechanism an external client would use (for testing coverage) + // The following would work for us, but not external users: + // val intent = Intent(this, MeshService::class.java) + // intent.action = IMeshService::class.java.name + + // Before binding we want to explicitly create - so the service stays alive forever (so it can keep + // listening for the bluetooth packets arriving from the radio. And when they arrive forward them + // to Signal or whatever. + info("Trying to start service debug=${BuildConfig.DEBUG}") + + val intent = createIntent() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + context.startForegroundService(intent) + } catch (ex: ForegroundServiceStartNotAllowedException) { + errormsg("Unable to start service: ${ex.message}") + } + } else { + context.startForegroundService(intent) + } + } else { + context.startService(intent) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt new file mode 100644 index 000000000..21454800d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt @@ -0,0 +1,4 @@ +package com.geeksville.mesh.service + +open class RadioNotConnectedException(message: String = "Not connected to radio") : + BLEException(message) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt new file mode 100644 index 000000000..39a478528 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/SafeBluetooth.kt @@ -0,0 +1,809 @@ +package com.geeksville.mesh.service + +import android.bluetooth.* +import android.content.Context +import android.os.Build +import android.os.DeadObjectException +import android.os.Handler +import android.os.Looper +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.concurrent.CallbackContinuation +import com.geeksville.mesh.concurrent.Continuation +import com.geeksville.mesh.concurrent.SyncContinuation +import com.geeksville.mesh.android.bluetoothManager +import com.geeksville.mesh.util.exceptionReporter +import kotlinx.coroutines.* +import java.io.Closeable +import java.util.* + + +/// Return a standard BLE 128 bit UUID from the short 16 bit versions +fun longBLEUUID(hexFour: String): UUID = UUID.fromString("0000$hexFour-0000-1000-8000-00805f9b34fb") + + +/** + * Uses coroutines to safely access a bluetooth GATT device with a synchronous API + * + * The BTLE API on android is dumb. You can only have one outstanding operation in flight to + * the device. If you try to do something when something is pending, the operation just returns + * false. You are expected to chain your operations from the results callbacks. + * + * This class fixes the API by using coroutines to let you safely do a series of BTLE operations. + */ +class SafeBluetooth(private val context: Context, private val device: BluetoothDevice) : + Logging, Closeable { + + /// Timeout before we declare a bluetooth operation failed (used for synchronous API operations only) + var timeoutMsec = 20 * 1000L + + /// Users can access the GATT directly as needed + @Volatile + var gatt: BluetoothGatt? = null + + @Volatile + var state = BluetoothProfile.STATE_DISCONNECTED + + @Volatile + private var currentWork: BluetoothContinuation? = null + private val workQueue = mutableListOf() + + // Called for reconnection attemps + @Volatile + private var connectionCallback: ((Result) -> Unit)? = null + + @Volatile + private var lostConnectCallback: (() -> Unit)? = null + + /// from characteristic UUIDs to the handler function for notfies + private val notifyHandlers = mutableMapOf Unit>() + + private val serviceScope = CoroutineScope(Dispatchers.IO) + + /** + * A BLE status code based error + */ + class BLEStatusException(val status: Int, msg: String) : BLEException(msg) + + // 0x2902 org.bluetooth.descriptor.gatt.client_characteristic_configuration.xml + private val configurationDescriptorUUID = + longBLEUUID("2902") + + /** + * a schedulable bit of bluetooth work, includes both the closure to call to start the operation + * and the completion (either async or sync) to call when it completes + */ + private class BluetoothContinuation( + val tag: String, + val completion: com.geeksville.mesh.concurrent.Continuation<*>, + val timeoutMillis: Long = 0, // If we want to timeout this operation at a certain time, use a non zero value + private val startWorkFn: () -> Boolean + ) : Logging { + + /// Start running a queued bit of work, return true for success or false for fatal bluetooth error + fun startWork(): Boolean { + debug("Starting work: $tag") + return startWorkFn() + } + + override fun toString(): String { + return "Work:$tag" + } + + /// Connection work items are treated specially + fun isConnect() = tag == "connect" || tag == "reconnect" + } + + /** + * skanky hack to restart BLE if it says it is hosed + * https://stackoverflow.com/questions/35103701/ble-android-onconnectionstatechange-not-being-called + */ + private val mHandler: Handler = Handler(Looper.getMainLooper()) + + fun restartBle() { + GeeksvilleApplication.analytics.track("ble_restart") // record # of times we needed to use this nasty hack + errormsg("Doing emergency BLE restart") + context.bluetoothManager?.adapter?.let { adp -> + if (adp.isEnabled) { + adp.disable() + // TODO: display some kind of UI about restarting BLE + mHandler.postDelayed(object : Runnable { + override fun run() { + if (!adp.isEnabled) { + adp.enable() + } else { + mHandler.postDelayed(this, 2500) + } + } + }, 2500) + } + } + } + + // Our own custom BLE status codes + private val STATUS_RELIABLE_WRITE_FAILED = 4403 + private val STATUS_TIMEOUT = 4404 + private val STATUS_NOSTART = 4405 + private val STATUS_SIMFAILURE = 4406 + + /** + * Should we automatically try to reconnect when we lose our connection? + * + * Originally this was true, but over time (now that clients are smarter and need to build + * up more state) I see this was a mistake. Now if the connection drops we just call + * the lostConnection callback and the client of this API is responsible for reconnecting. + * This also prevents nasty races when sometimes both the upperlayer and this layer decide to reconnect + * simultaneously. + */ + private val autoReconnect = false + + private val gattCallback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange( + g: BluetoothGatt, + status: Int, + newState: Int + ) = exceptionReporter { + info("new bluetooth connection state $newState, status $status") + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + state = + newState // we only care about connected/disconnected - not the transitional states + + // If autoconnect is on and this connect attempt failed, hopefully some future attempt will succeed + if (status != BluetoothGatt.GATT_SUCCESS && autoConnect) { + errormsg("Connect attempt failed $status, not calling connect completion handler...") + } else + completeWork(status, Unit) + } + BluetoothProfile.STATE_DISCONNECTED -> { + if (gatt == null) { + errormsg("No gatt: ignoring connection state $newState, status $status") + } else if (isClosing) { + info("Got disconnect because we are shutting down, closing gatt") + gatt = null + g.close() // Finish closing our gatt here + } else { + // cancel any queued ops if we were already connected + val oldstate = state + state = newState + if (oldstate == BluetoothProfile.STATE_CONNECTED) { + info("Lost connection - aborting current work: $currentWork") + + // If we get a disconnect, just try again otherwise fail all current operations + // Note: if no work is pending (likely) we also just totally teardown and restart the connection, because we won't be + // throwing a lost connection exception to any worker. + if (autoReconnect && (currentWork == null || currentWork?.isConnect() == true)) + dropAndReconnect() + else + lostConnection("lost connection") + } else if (status == 133) { + // We were not previously connected and we just failed with our non-auto connection attempt. Therefore we now need + // to do an autoconnection attempt. When that attempt succeeds/fails the normal callbacks will be called + + // Note: To workaround https://issuetracker.google.com/issues/36995652 + // Always call BluetoothDevice#connectGatt() with autoConnect=false + // (the race condition does not affect that case). If that connection times out + // you will get a callback with status=133. Then call BluetoothGatt#connect() + // to initiate a background connection. + if (autoConnect) { + warn("Failed on non-auto connect, falling back to auto connect attempt") + closeGatt() // Close the old non-auto connection + lowLevelConnect(true) + } + } + + if (status == 257) { // mystery error code when phone is hung + //throw Exception("Mystery bluetooth failure - debug me") + restartBle() + } + } + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + // For testing lie and claim failure + completeWork(status, Unit) + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + completeWork(status, characteristic) + } + + override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) { + completeWork(status, Unit) + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + val reliable = currentReliableWrite + if (reliable != null) + if (!characteristic.value.contentEquals(reliable)) { + errormsg("A reliable write failed!") + gatt.abortReliableWrite() + completeWork( + STATUS_RELIABLE_WRITE_FAILED, + characteristic + ) // skanky code to indicate failure + } else { + logAssert(gatt.executeReliableWrite()) + // After this execute reliable completes - we can continue with normal operations (see onReliableWriteCompleted) + } + else // Just a standard write - do the normal flow + completeWork(status, characteristic) + } + + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + // Alas, passing back an Int mtu isn't working and since I don't really care what MTU + // the device was willing to let us have I'm just punting and returning Unit + if (isSettingMtu) + completeWork(status, Unit) + else + errormsg("Ignoring bogus onMtuChanged") + } + + /** + * Callback triggered as a result of a remote characteristic notification. + * + * @param gatt GATT client the characteristic is associated with + * @param characteristic Characteristic that has been updated as a result of a remote + * notification event. + */ + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + val handler = notifyHandlers.get(characteristic.uuid) + if (handler == null) + warn("Received notification from $characteristic, but no handler registered") + else { + exceptionReporter { + handler(characteristic) + } + } + } + + /** + * Callback indicating the result of a descriptor write operation. + * + * @param gatt GATT client invoked [BluetoothGatt.writeDescriptor] + * @param descriptor Descriptor that was writte to the associated remote device. + * @param status The result of the write operation [BluetoothGatt.GATT_SUCCESS] if the + * operation succeeds. + */ + override fun onDescriptorWrite( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + completeWork(status, descriptor) + } + + /** + * Callback reporting the result of a descriptor read operation. + * + * @param gatt GATT client invoked [BluetoothGatt.readDescriptor] + * @param descriptor Descriptor that was read from the associated remote device. + * @param status [BluetoothGatt.GATT_SUCCESS] if the read operation was completed + * successfully + */ + override fun onDescriptorRead( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + completeWork(status, descriptor) + } + } + + // To test loss of BLE faults we can randomly fail a certain % of all work items. We + // skip this for "connect" items because the handling for connection failure is special + var simFailures = false + var failPercent = + 10 // 15% failure is unusably high because of constant reconnects, 7% somewhat usable, 10% pretty bad + private val failRandom = Random() + + private var activeTimeout: Job? = null + + /// If we have work we can do, start doing it. + private fun startNewWork() { + logAssert(currentWork == null) + + if (workQueue.isNotEmpty()) { + val newWork = workQueue.removeAt(0) + currentWork = newWork + + if (newWork.timeoutMillis != 0L) { + + activeTimeout = serviceScope.launch { + // debug("Starting failsafe timer ${newWork.timeoutMillis}") + delay(newWork.timeoutMillis) + errormsg("Failsafe BLE timer expired!") + completeWork( + STATUS_TIMEOUT, + Unit + ) // Throw an exception in that work + } + } + + isSettingMtu = + false // Most work is not doing MTU stuff, the work that is will re set this flag + + val failThis = + simFailures && !newWork.isConnect() && failRandom.nextInt(100) < failPercent + + if (failThis) { + errormsg("Simulating random work failure!") + completeWork(STATUS_SIMFAILURE, Unit) + } else { + val started = newWork.startWork() + if (!started) { + errormsg("Failed to start work, returned error status") + completeWork( + STATUS_NOSTART, + Unit + ) // abandon the current attempt and try for another + } + } + } + } + + private fun queueWork( + tag: String, + cont: Continuation, + timeout: Long, + initFn: () -> Boolean + ) { + val btCont = + BluetoothContinuation( + tag, + cont, + timeout, + initFn + ) + + synchronized(workQueue) { + debug("Enqueuing work: ${btCont.tag}") + workQueue.add(btCont) + + // if we don't have any outstanding operations, run first item in queue + if (currentWork == null) + startNewWork() + } + } + + /** + * Stop any current work + */ + private fun stopCurrentWork() { + activeTimeout?.let { + it.cancel() + activeTimeout = null + } + currentWork = null + } + + /** + * Called from our big GATT callback, completes the current job and then schedules a new one + */ + private fun completeWork(status: Int, res: T) { + exceptionReporter { + // We might unexpectedly fail inside here, but we don't want to pass that exception back up to the bluetooth GATT layer + + // startup next job in queue before calling the completion handler + val work = + synchronized(workQueue) { + val w = currentWork + + if (w != null) { + stopCurrentWork() // We are now no longer working on anything + + startNewWork() + } + w + } + + if (work == null) + warn("wor completed, but we already killed it via failsafetimer? status=$status, res=$res") + else { + // debug("work ${work.tag} is completed, resuming status=$status, res=$res") + if (status != 0) + work.completion.resumeWithException( + BLEStatusException( + status, + "Bluetooth status=$status while doing ${work.tag}" + ) + ) + else + work.completion.resume(Result.success(res) as Result) + } + } + } + + /** + * Something went wrong, abort all queued + */ + private fun failAllWork(ex: Exception) { + synchronized(workQueue) { + warn("Failing ${workQueue.size} works, because ${ex.message}") + workQueue.forEach { + try { + it.completion.resumeWithException(ex) + } catch (ex: Exception) { + errormsg( + "Mystery exception, why were we informed about our own exceptions?", + ex + ) + } + } + workQueue.clear() + stopCurrentWork() + } + } + + /// helper glue to make sync continuations and then wait for the result + private fun makeSync(wrappedFn: (SyncContinuation) -> Unit): T { + val cont = SyncContinuation() + wrappedFn(cont) + return cont.await() // was timeoutMsec but we now do the timeout at the lower BLE level + } + + // Is the gatt trying to repeatedly connect as needed? + private var autoConnect = false + + /// True if the current active connection is auto (possible for this to be false but autoConnect to be true + /// if we are in the first non-automated lowLevel connect. + private var currentConnectIsAuto = false + + private fun lowLevelConnect(autoNow: Boolean): BluetoothGatt? { + currentConnectIsAuto = autoNow + logAssert(gatt == null) + + val g = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt( + context, + autoNow, + gattCallback, + BluetoothDevice.TRANSPORT_LE + ) + } else { + device.connectGatt( + context, + autoNow, + gattCallback + ) + } + + gatt = g + return g + } + + // FIXME, pass in true for autoconnect - so we will autoconnect whenever the radio + // comes in range (even if we made this connect call long ago when we got powered on) + // see https://stackoverflow.com/questions/40156699/which-correct-flag-of-autoconnect-in-connectgatt-of-ble for + // more info. + // Otherwise if you pass in false, it will try to connect now and will timeout and fail in 30 seconds. + private fun queueConnect( + autoConnect: Boolean = false, + cont: Continuation, + timeout: Long = 0 + ) { + this.autoConnect = autoConnect + + // assert(gatt == null) this now might be !null with our new reconnect support + queueWork("connect", cont, timeout) { + + // Note: To workaround https://issuetracker.google.com/issues/36995652 + // Always call BluetoothDevice#connectGatt() with autoConnect=false + // (the race condition does not affect that case). If that connection times out + // you will get a callback with status=133. Then call BluetoothGatt#connect() + // to initiate a background connection. + val g = lowLevelConnect(false) + g != null + } + } + + /** + * start a connection attempt. + * + * Note: if autoConnect is true, the callback you provide will be kept around _even after the connection is complete. + * If we ever lose the connection, this class will immediately requque the attempt (after canceling + * any outstanding queued operations). + * + * So you should expect your callback might be called multiple times, each time to reestablish a new connection. + */ + fun asyncConnect( + autoConnect: Boolean = false, + cb: (Result) -> Unit, + lostConnectCb: () -> Unit + ) { + logAssert(workQueue.isEmpty()) + if (currentWork != null) + throw AssertionError("currentWork was not null: $currentWork") + + lostConnectCallback = lostConnectCb + connectionCallback = if (autoConnect) + cb + else + null + queueConnect(autoConnect, CallbackContinuation(cb)) + } + + /// Restart any previous connect attempts + private fun reconnect() { + // closeGatt() // Get rid of any old gatt + + connectionCallback?.let { cb -> + queueConnect(true, CallbackContinuation(cb)) + } + } + + private fun lostConnection(reason: String) { + /* + Supposedly this reconnect attempt happens automatically + "If the connection was established through an auto connect, Android will + automatically try to reconnect to the remote device when it gets disconnected + until you manually call disconnect() or close(). Once a connection established + through direct connect disconnects, no attempt is made to reconnect to the remote device." + https://stackoverflow.com/questions/37965337/what-exactly-does-androids-bluetooth-autoconnect-parameter-do?rq=1 + + closeConnection() + */ + failAllWork(BLEException(reason)) + + // Cancel any notifications - because when the device comes back it might have forgotten about us + notifyHandlers.clear() + + lostConnectCallback?.let { + debug("calling lostConnect handler") + it.invoke() + } + } + + /// Drop our current connection and then requeue a connect as needed + private fun dropAndReconnect() { + lostConnection("lost connection, reconnecting") + + // Queue a new connection attempt + val cb = connectionCallback + if (cb != null) { + debug("queuing a reconnection callback") + assert(currentWork == null) + + if (!currentConnectIsAuto) { // we must have been running during that 1-time manual connect, switch to auto-mode from now on + closeGatt() // Close the old non-auto connection + lowLevelConnect(true) + } + + // note - we don't need an init fn (because that would normally redo the connectGatt call - which we don't need) + queueWork("reconnect", CallbackContinuation(cb), 0) { -> true } + } else { + debug("No connectionCallback registered") + } + } + + fun connect(autoConnect: Boolean = false) = + makeSync { queueConnect(autoConnect, it) } + + private fun queueReadCharacteristic( + c: BluetoothGattCharacteristic, + cont: Continuation, timeout: Long = 0 + ) = queueWork("readC ${c.uuid}", cont, timeout) { gatt!!.readCharacteristic(c) } + + fun asyncReadCharacteristic( + c: BluetoothGattCharacteristic, + cb: (Result) -> Unit + ) = queueReadCharacteristic(c, CallbackContinuation(cb)) + + fun readCharacteristic( + c: BluetoothGattCharacteristic, + timeout: Long = timeoutMsec + ): BluetoothGattCharacteristic = + makeSync { queueReadCharacteristic(c, it, timeout) } + + private fun queueDiscoverServices(cont: Continuation, timeout: Long = 0) { + queueWork("discover", cont, timeout) { + gatt?.discoverServices() + ?: false // throw BLEException("GATT is null") - if we return false here it is probably because the device is being torn down + } + } + + fun asyncDiscoverServices(cb: (Result) -> Unit) { + queueDiscoverServices(CallbackContinuation(cb)) + } + + fun discoverServices() = makeSync { queueDiscoverServices(it) } + + /** + * On some phones we receive bogus mtu gatt callbacks, we need to ignore them if we weren't setting the mtu + */ + private var isSettingMtu = false + + /** + * mtu operations seem to hang sometimes. To cope with this we have a 5 second timeout before throwing an exception and cancelling the work + */ + private fun queueRequestMtu( + len: Int, + cont: Continuation + ) = queueWork("reqMtu", cont, 10 * 1000) { + isSettingMtu = true + gatt?.requestMtu(len) ?: false + } + + fun asyncRequestMtu( + len: Int, + cb: (Result) -> Unit + ) { + queueRequestMtu(len, CallbackContinuation(cb)) + } + + fun requestMtu(len: Int): Unit = makeSync { queueRequestMtu(len, it) } + + private var currentReliableWrite: ByteArray? = null + + private fun queueWriteCharacteristic( + c: BluetoothGattCharacteristic, + v: ByteArray, + cont: Continuation, timeout: Long = 0 + ) = queueWork("writeC ${c.uuid}", cont, timeout) { + currentReliableWrite = null + c.value = v + gatt?.writeCharacteristic(c) ?: false + } + + fun asyncWriteCharacteristic( + c: BluetoothGattCharacteristic, + v: ByteArray, + cb: (Result) -> Unit + ) = queueWriteCharacteristic(c, v, CallbackContinuation(cb)) + + fun writeCharacteristic( + c: BluetoothGattCharacteristic, + v: ByteArray, + timeout: Long = timeoutMsec + ): BluetoothGattCharacteristic = + makeSync { queueWriteCharacteristic(c, v, it, timeout) } + + /** Like write, but we use the extra reliable flow documented here: + * https://stackoverflow.com/questions/24485536/what-is-reliable-write-in-ble + */ + private fun queueWriteReliable( + c: BluetoothGattCharacteristic, + cont: Continuation, timeout: Long = 0 + ) = queueWork("rwriteC ${c.uuid}", cont, timeout) { + logAssert(gatt!!.beginReliableWrite()) + currentReliableWrite = c.value.clone() + gatt?.writeCharacteristic(c) ?: false + } + + fun asyncWriteReliable( + c: BluetoothGattCharacteristic, + cb: (Result) -> Unit + ) = queueWriteReliable(c, CallbackContinuation(cb)) + + fun writeReliable(c: BluetoothGattCharacteristic): Unit = + makeSync { queueWriteReliable(c, it) } + + private fun queueWriteDescriptor( + c: BluetoothGattDescriptor, + cont: Continuation, timeout: Long = 0 + ) = queueWork("writeD", cont, timeout) { gatt?.writeDescriptor(c) ?: false } + + fun asyncWriteDescriptor( + c: BluetoothGattDescriptor, + cb: (Result) -> Unit + ) = queueWriteDescriptor(c, CallbackContinuation(cb)) + + /** + * Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback gets called + * but the only safe way to call gatt.close is from that callback. So we set a flag once we start closing and then poll + * until we see the callback has set gatt to null (indicating the CALLBACK has close the gatt). If the timeout expires we assume the bug + * has occurred, and we manually close the gatt here. + * + * Log of typical failure + * 06-29 08:47:15.035 29788-30155/com.geeksville.mesh D/BluetoothGatt: cancelOpen() - device: 24:62:AB:F8:40:9A + 06-29 08:47:15.036 29788-30155/com.geeksville.mesh D/BluetoothGatt: close() + 06-29 08:47:15.037 29788-30155/com.geeksville.mesh D/BluetoothGatt: unregisterApp() - mClientIf=5 + 06-29 08:47:15.037 29788-29813/com.geeksville.mesh D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=5 device=24:62:AB:F8:40:9A + 06-29 08:47:15.037 29788-29813/com.geeksville.mesh W/BluetoothGatt: Unhandled exception in callback + java.lang.NullPointerException: Attempt to invoke virtual method 'void android.bluetooth.BluetoothGattCallback.onConnectionStateChange(android.bluetooth.BluetoothGatt, int, int)' on a null object reference + at android.bluetooth.BluetoothGatt$1.onClientConnectionState(BluetoothGatt.java:182) + at android.bluetooth.IBluetoothGattCallback$Stub.onTransact(IBluetoothGattCallback.java:70) + at android.os.Binder.execTransact(Binder.java:446) + * + * per https://github.com/don/cordova-plugin-ble-central/issues/473#issuecomment-367687575 + */ + @Volatile + private var isClosing = false + + /** Close just the GATT device but keep our pending callbacks active */ + fun closeGatt() { + + gatt?.let { g -> + info("Closing our GATT connection") + isClosing = true + try { + g.disconnect() + + // Wait for our callback to run and handle hte disconnect + var msecsLeft = 1000 + while (gatt != null && msecsLeft >= 0) { + Thread.sleep(100) + msecsLeft -= 100 + } + + gatt?.let { g2 -> + warn("Android onConnectionStateChange did not run, manually closing") + gatt = + null // clear gat before calling close, bcause close might throw dead object exception + g2.close() + } + } catch (ex: NullPointerException) { + // Attempt to invoke virtual method 'com.android.bluetooth.gatt.AdvertiseClient com.android.bluetooth.gatt.AdvertiseManager.getAdvertiseClient(int)' on a null object reference + //com.geeksville.mesh.service.SafeBluetooth.closeGatt + warn("Ignoring NPE in close - probably buggy Samsung BLE") + } catch (ex: DeadObjectException) { + warn("Ignoring dead object exception, probably bluetooth was just disabled") + } finally { + isClosing = false + } + } + } + + /** + * Close down any existing connection, any existing calls (including async connects will be + * cancelled and you'll need to recall connect to use this againt + */ + fun closeConnection() { + // Set these to null _before_ calling gatt.disconnect(), because we don't want the old lostConnectCallback to get called + lostConnectCallback = null + connectionCallback = null + + // Cancel any notifications - because when the device comes back it might have forgotten about us + notifyHandlers.clear() + + closeGatt() + + failAllWork(BLEConnectionClosing()) + } + + /** + * Close and destroy this SafeBluetooth instance. You'll need to make a new instance before using it again + */ + override fun close() { + closeConnection() + + // context.unregisterReceiver(btStateReceiver) + } + + + /// asyncronously turn notification on/off for a characteristic + fun setNotify( + c: BluetoothGattCharacteristic, + enable: Boolean, + onChanged: (BluetoothGattCharacteristic) -> Unit + ) { + debug("starting setNotify(${c.uuid}, $enable)") + notifyHandlers[c.uuid] = onChanged + // c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + gatt!!.setCharacteristicNotification(c, enable) + + // per https://stackoverflow.com/questions/27068673/subscribe-to-a-ble-gatt-notification-android + val descriptor: BluetoothGattDescriptor = c.getDescriptor(configurationDescriptorUUID) + ?: throw BLEException("Notify descriptor not found for ${c.uuid}") // This can happen on buggy BLE implementations + descriptor.value = + if (enable) BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + asyncWriteDescriptor(descriptor) { + debug("Notify enable=$enable completed") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt b/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt new file mode 100644 index 000000000..c1fea6773 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/ServiceRepository.kt @@ -0,0 +1,71 @@ +package com.geeksville.mesh.service + +import com.geeksville.mesh.IMeshService +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.android.Logging +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository class for managing the [IMeshService] instance and connection state + */ +@Singleton +class ServiceRepository @Inject constructor() : Logging { + var meshService: IMeshService? = null + private set + + fun setMeshService(service: IMeshService?) { + meshService = service + } + + // Connection state to our radio device + private val _connectionState = MutableStateFlow(MeshService.ConnectionState.DISCONNECTED) + val connectionState: StateFlow get() = _connectionState + + fun setConnectionState(connectionState: MeshService.ConnectionState) { + _connectionState.value = connectionState + } + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow get() = _errorMessage + + fun setErrorMessage(text: String) { + errormsg(text) + _errorMessage.value = text + } + + fun clearErrorMessage() { + _errorMessage.value = null + } + + private val _statusMessage = MutableStateFlow(null) + val statusMessage: StateFlow get() = _statusMessage + + fun setStatusMessage(text: String) { + if (connectionState.value != MeshService.ConnectionState.CONNECTED) { + _statusMessage.value = text + } + } + + private val _meshPacketFlow = MutableSharedFlow() + val meshPacketFlow: SharedFlow get() = _meshPacketFlow + + suspend fun emitMeshPacket(packet: MeshPacket) { + _meshPacketFlow.emit(packet) + } + + private val _tracerouteResponse = MutableStateFlow(null) + val tracerouteResponse: StateFlow get() = _tracerouteResponse + + fun setTracerouteResponse(value: String?) { + _tracerouteResponse.value = value + } + + fun clearTracerouteResponse() { + setTracerouteResponse(null) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt new file mode 100644 index 000000000..16ac63e59 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/SoftwareUpdateService.kt @@ -0,0 +1,60 @@ +package com.geeksville.mesh.service + +import android.bluetooth.BluetoothGattCharacteristic + +/** + * Some misformatted ESP32s have problems + */ +class DeviceRejectedException : BLEException("Device rejected filesize") + +/** + * Move this somewhere as a generic network byte order function + */ +fun toNetworkByteArray(value: Int, formatType: Int): ByteArray { + + val len = when (formatType) { + BluetoothGattCharacteristic.FORMAT_UINT8 -> 1 + BluetoothGattCharacteristic.FORMAT_UINT32 -> 4 + else -> TODO() + } + + val mValue = ByteArray(len) + + when (formatType) { + /* BluetoothGattCharacteristic.FORMAT_SINT8 -> { + value = intToSignedBits(value, 8) + mValue.get(offset) = (value and 0xFF).toByte() + } + BluetoothGattCharacteristic.FORMAT_UINT8 -> mValue.get(offset) = + (value and 0xFF).toByte() + BluetoothGattCharacteristic.FORMAT_SINT16 -> { + value = intToSignedBits(value, 16) + mValue.get(offset++) = (value and 0xFF).toByte() + mValue.get(offset) = (value shr 8 and 0xFF).toByte() + } + BluetoothGattCharacteristic.FORMAT_UINT16 -> { + mValue.get(offset++) = (value and 0xFF).toByte() + mValue.get(offset) = (value shr 8 and 0xFF).toByte() + } + BluetoothGattCharacteristic.FORMAT_SINT32 -> { + value = intToSignedBits(value, 32) + mValue.get(offset++) = (value and 0xFF).toByte() + mValue.get(offset++) = (value shr 8 and 0xFF).toByte() + mValue.get(offset++) = (value shr 16 and 0xFF).toByte() + mValue.get(offset) = (value shr 24 and 0xFF).toByte() + } */ + BluetoothGattCharacteristic.FORMAT_UINT8 -> + mValue[0] = (value and 0xFF).toByte() + + BluetoothGattCharacteristic.FORMAT_UINT32 -> { + mValue[0] = (value and 0xFF).toByte() + mValue[1] = (value shr 8 and 0xFF).toByte() + mValue[2] = (value shr 16 and 0xFF).toByte() + mValue[3] = (value shr 24 and 0xFF).toByte() + } + else -> TODO() + } + return mValue +} + +data class UpdateFilenames(val appLoad: String?, val littlefs: String?) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/BatteryInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/BatteryInfo.kt new file mode 100644 index 000000000..102ec4f64 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/BatteryInfo.kt @@ -0,0 +1,93 @@ +package com.geeksville.mesh.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.theme.AppTheme + +@Composable +fun BatteryInfo( + modifier: Modifier = Modifier, + batteryLevel: Int?, + voltage: Float? +) { + val infoString = "%d%% %.2fV".format(batteryLevel, voltage) + val (image, level) = when (batteryLevel) { + in 0 .. 4 -> R.drawable.ic_battery_alert to " $infoString" + in 5 .. 14 -> R.drawable.ic_battery_outline to infoString + in 15..34 -> R.drawable.ic_battery_low to infoString + in 35..79 -> R.drawable.ic_battery_medium to infoString + in 80..100 -> R.drawable.ic_battery_high to infoString + 101 -> R.drawable.ic_power_plug_24 to "%.2fV".format(voltage) + else -> R.drawable.ic_battery_unknown to (voltage?.let { "%.2fV".format(it) } ?: "") + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.height(18.dp), + imageVector = ImageVector.vectorResource(id = image), + contentDescription = null, + tint = MaterialTheme.colors.onSurface, + ) + Text( + text = level, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +@PreviewLightDark +@Composable +fun BatteryInfoPreview( + @PreviewParameter(BatteryInfoPreviewParameterProvider::class) + batteryInfo: Pair +) { + AppTheme { + BatteryInfo( + batteryLevel = batteryInfo.first, + voltage = batteryInfo.second + ) + } +} + +@Composable +@Preview +fun BatteryInfoPreviewSimple() { + AppTheme { + BatteryInfo( + batteryLevel = 85, + voltage = 3.7F + ) + } +} + +class BatteryInfoPreviewParameterProvider : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + 85 to 3.7F, + 2 to 3.7F, + 12 to 3.7F, + 28 to 3.7F, + 50 to 3.7F, + 101 to 4.9F, + null to 4.5F, + null to null + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt new file mode 100644 index 000000000..d8acd863d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -0,0 +1,541 @@ +package com.geeksville.mesh.ui + +import android.net.Uri +import android.os.Bundle +import android.os.RemoteException +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Check +import androidx.compose.material.icons.twotone.Close +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.icons.twotone.ContentCopy +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.analytics.DataPair +import com.geeksville.mesh.android.GeeksvilleApplication +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.android.BuildUtils.debug +import com.geeksville.mesh.android.BuildUtils.errormsg +import com.geeksville.mesh.android.getCameraPermissions +import com.geeksville.mesh.android.hasCameraPermission +import com.geeksville.mesh.channelSet +import com.geeksville.mesh.channelSettings +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.ChannelOption +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.getChannelUrl +import com.geeksville.mesh.model.qrCode +import com.geeksville.mesh.model.toChannelSet +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.components.AdaptiveTwoPane +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.ScannedQrCodeDialog +import com.geeksville.mesh.ui.components.config.ChannelCard +import com.geeksville.mesh.ui.components.config.ChannelSelection +import com.geeksville.mesh.ui.components.config.EditChannelDialog +import com.geeksville.mesh.ui.components.dragContainer +import com.geeksville.mesh.ui.components.dragDropItemsIndexed +import com.geeksville.mesh.ui.components.rememberDragDropState +import com.google.accompanist.themeadapter.appcompat.AppCompatTheme +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ChannelFragment : ScreenFragment("Channel"), Logging { + + private val model: UIViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppCompatTheme { + ChannelScreen(model) + } + } + } + } +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun ChannelScreen( + viewModel: UIViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val clipboardManager = LocalClipboardManager.current + + val connectionState by viewModel.connectionState.observeAsState() + val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged + + val channels by viewModel.channels.collectAsStateWithLifecycle() + var channelSet by remember(channels) { mutableStateOf(channels) } + var showChannelEditor by rememberSaveable { mutableStateOf(false) } + val isEditing = channelSet != channels || showChannelEditor + + /* Holds selections made by the user for QR generation. */ + val channelSelections = rememberSaveable( + saver = listSaver( + save = { stateList -> stateList.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf(elements = Array(size = 8, init = { true })) } + + val channelUrl = channelSet.getChannelUrl() + val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name + + var scannedChannelSet by remember { mutableStateOf(null) } + val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + try { + scannedChannelSet = Uri.parse(result.contents).toChannelSet() + } catch (ex: Throwable) { + errormsg("Channel url error: ${ex.message}") + viewModel.showSnackbar(R.string.channel_invalid) + } + } + } + + fun updateSettingsList(update: MutableList.() -> Unit) { + try { + val list = channelSet.settingsList.toMutableList() + list.update() + channelSet = channelSet.copy { + settings.clear() + settings.addAll(list) + } + } catch (ex: Exception) { + errormsg("Error updating ChannelSettings list:", ex) + } + } + + fun zxingScan() { + debug("Starting zxing QR code scanner") + val zxingScan = ScanOptions() + zxingScan.setCameraId(0) + zxingScan.setPrompt("") + zxingScan.setBeepEnabled(false) + zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE) + barcodeLauncher.launch(zxingScan) + } + + val requestPermissionAndScanLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.entries.all { it.value }) zxingScan() + } + + fun requestPermissionAndScan() { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.camera_required) + .setMessage(R.string.why_camera_required) + .setNeutralButton(R.string.cancel) { _, _ -> + debug("Camera permission denied") + } + .setPositiveButton(R.string.accept) { _, _ -> + requestPermissionAndScanLauncher.launch(context.getCameraPermissions()) + } + .show() + } + + // Send new channel settings to the device + fun installSettings( + newChannelSet: ChannelSet + ) { + // Try to change the radio, if it fails, tell the user why and throw away their edits + try { + viewModel.setChannels(newChannelSet) + // Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc) + } catch (ex: RemoteException) { + errormsg("ignoring channel problem", ex) + + channelSet = channels // Throw away user edits + + // Tell the user to try again + viewModel.showSnackbar(R.string.cant_change_no_radio) + } finally { + showChannelEditor = false + } + } + + fun installSettings( + newChannel: ChannelProtos.ChannelSettings, + newLoRaConfig: ConfigProtos.Config.LoRaConfig + ) { + val newSet = channelSet { + settings.add(newChannel) + loraConfig = newLoRaConfig + } + installSettings(newSet) + } + + fun resetButton() { + // User just locked it, we should warn and then apply changes to radio + MaterialAlertDialogBuilder(context) + .setTitle(R.string.reset_to_defaults) + .setMessage(R.string.are_you_sure_change_default) + .setNeutralButton(R.string.cancel) { _, _ -> + channelSet = channels // throw away any edits + } + .setPositiveButton(R.string.apply) { _, _ -> + debug("Switching back to default channel") + installSettings( + Channel.default.settings, + Channel.default.loraConfig.copy { + region = viewModel.region + txEnabled = viewModel.txEnabled + } + ) + } + .show() + } + + fun sendButton() { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.change_channel) + .setMessage(R.string.are_you_sure_channel) + .setNeutralButton(R.string.cancel) { _, _ -> + showChannelEditor = false + channelSet = channels + } + .setPositiveButton(R.string.accept) { _, _ -> + installSettings(channelSet) + } + .show() + } + + if (scannedChannelSet != null) { + val incoming = scannedChannelSet ?: return + /* Prompt the user to modify channels after scanning a QR code. */ + ScannedQrCodeDialog( + channels = channels, + incoming = incoming, + onDismiss = { scannedChannelSet = null }, + onConfirm = { newChannelSet -> installSettings(newChannelSet) } + ) + } + + var showEditChannelDialog: Int? by remember { mutableStateOf(null) } + + if (showEditChannelDialog != null) { + val index = showEditChannelDialog ?: return + EditChannelDialog( + channelSettings = with(channelSet) { + if (settingsCount > index) getSettings(index) else channelSettings { } + }, + modemPresetName = modemPresetName, + onAddClick = { + with(channelSet) { + if (settingsCount > index) channelSet = copy { settings[index] = it } + else channelSet = copy { settings.add(it) } + } + showEditChannelDialog = null + }, + onDismissRequest = { showEditChannelDialog = null } + ) + } + + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex -> + updateSettingsList { add(toIndex, removeAt(fromIndex)) } + } + + LazyColumn( + modifier = Modifier.dragContainer( + dragDropState = dragDropState, + haptics = LocalHapticFeedback.current, + ), + state = listState, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), + ) { + if (!showChannelEditor) { + item { + ChannelListView( + enabled = enabled, + channelSet = channelSet, + modemPresetName = modemPresetName, + channelSelections = channelSelections, + onClick = { showChannelEditor = true } + ) + } + } else { + dragDropItemsIndexed( + items = channelSet.settingsList, + dragDropState = dragDropState, + ) { index, channel, isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 4.dp, label = "drag") + ChannelCard( + elevation = elevation, + index = index, + title = channel.name.ifEmpty { modemPresetName }, + enabled = enabled, + onEditClick = { showEditChannelDialog = index }, + onDeleteClick = { updateSettingsList { removeAt(index) } } + ) + } + item { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + channelSet = channelSet.copy { + settings.add(channelSettings { psk = Channel.default.settings.psk }) + } + showEditChannelDialog = channelSet.settingsList.lastIndex + }, + enabled = enabled && viewModel.maxChannels > channelSet.settingsCount, + colors = ButtonDefaults.buttonColors( + disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + ) + ) { Text(text = stringResource(R.string.add)) } + } + } + + item { + var valueState by remember(channelUrl) { mutableStateOf(channelUrl) } + val isError = valueState != channelUrl + + OutlinedTextField( + value = valueState.toString(), + onValueChange = { + try { + valueState = Uri.parse(it) + channelSet = valueState.toChannelSet() + } catch (ex: Throwable) { + // channelSet failed to update, isError true + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + label = { Text("URL") }, + isError = isError, + trailingIcon = { + val isUrlEqual = channelUrl == channels.getChannelUrl() + IconButton(onClick = { + when { + isError -> valueState = channelUrl + !isUrlEqual -> viewModel.setRequestChannelUrl(channelUrl) + else -> { + // track how many times users share channels + GeeksvilleApplication.analytics.track( + "share", + DataPair("content_type", "channel") + ) + clipboardManager.setText(AnnotatedString(channelUrl.toString())) + } + } + }) { + Icon( + imageVector = when { + isError -> Icons.TwoTone.Close + !isUrlEqual -> Icons.TwoTone.Check + else -> Icons.TwoTone.ContentCopy + }, + contentDescription = when { + isError -> "Error" + !isUrlEqual -> stringResource(R.string.send) + else -> "Copy" + }, + tint = if (isError) { + MaterialTheme.colors.error + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } + ) + } + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) + } + + item { + DropDownPreference(title = stringResource(id = R.string.channel_options), + enabled = enabled, + items = ChannelOption.entries + .map { it.modemPreset to stringResource(it.configRes) }, + selectedItem = channelSet.loraConfig.modemPreset, + onItemSelected = { + val lora = channelSet.loraConfig.copy { modemPreset = it } + channelSet = channelSet.copy { loraConfig = lora } + }) + } + + if (isEditing) item { + PreferenceFooter( + enabled = enabled, + onCancelClicked = { + focusManager.clearFocus() + showChannelEditor = false + channelSet = channels + }, + onSaveClicked = { + focusManager.clearFocus() + // viewModel.setRequestChannelUrl(channelUrl) + sendButton() + }) + } else { + item { + PreferenceFooter( + enabled = enabled, + negativeText = R.string.reset, + onNegativeClicked = { + focusManager.clearFocus() + resetButton() + }, + positiveText = R.string.scan, + onPositiveClicked = { + focusManager.clearFocus() + // viewModel.setRequestChannelUrl(channelUrl) + if (context.hasCameraPermission()) zxingScan() else requestPermissionAndScan() + }) + } + } + } +} + +@Composable +private fun QrCodeImage( + enabled: Boolean, + channelSet: ChannelSet, + modifier: Modifier = Modifier, +) = Image( + painter = channelSet.qrCode + ?.let { BitmapPainter(it.asImageBitmap()) } + ?: painterResource(id = R.drawable.qrcode), + contentDescription = stringResource(R.string.qr_code), + modifier = modifier, + contentScale = ContentScale.Inside, + alpha = if (enabled) 1.0f else ContentAlpha.disabled, + // colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), +) + +@Composable +private fun ChannelListView( + enabled: Boolean, + channelSet: ChannelSet, + modemPresetName: String, + channelSelections: SnapshotStateList, + onClick: () -> Unit = {}, +) { + val selectedChannelSet = channelSet.copy { + val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } + settings.clear() + settings.addAll(result) + } + + AdaptiveTwoPane( + first = { + channelSet.settingsList.forEachIndexed { index, channel -> + ChannelSelection( + index = index, + title = channel.name.ifEmpty { modemPresetName }, + enabled = enabled, + isSelected = channelSelections[index], + onSelected = { + if (it || selectedChannelSet.settingsCount > 1) { + channelSelections[index] = it + } + }, + ) + } + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ), + ) { Text(text = stringResource(R.string.edit)) } + }, + second = { + QrCodeImage( + enabled = enabled, + channelSet = selectedChannelSet, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + }, + ) +} + +@PreviewScreenSizes +@Composable +private fun ChannelScreenPreview() { + ChannelListView( + enabled = true, + channelSet = channelSet { + settings.add(Channel.default.settings) + loraConfig = Channel.default.loraConfig + }, + modemPresetName = Channel.default.name, + channelSelections = listOf(true).toMutableStateList(), + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt new file mode 100644 index 000000000..30c3176af --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt @@ -0,0 +1,155 @@ +package com.geeksville.mesh.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.Contact +import com.geeksville.mesh.ui.theme.AppTheme + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun ContactItem( + contact: Contact, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +) = with(contact) { + Card( + modifier = Modifier + .background(color = if (selected) Color.Gray else MaterialTheme.colors.background) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + elevation = 4.dp, + shape = RoundedCornerShape(12.dp), + ) { + Surface( + modifier = modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Chip( + onClick = { }, + modifier = Modifier + .padding(end = 8.dp) + .width(72.dp), + ) { + Text( + text = shortName, + modifier = Modifier.fillMaxWidth(), + fontSize = MaterialTheme.typography.button.fontSize, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + } + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = longName, + modifier = Modifier.weight(1f), + ) + Text( + text = lastMessageTime.orEmpty(), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = lastMessageText.orEmpty(), + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + AnimatedVisibility(visible = isMuted) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_volume_off_24), + contentDescription = null, + ) + } + AnimatedVisibility(visible = unreadCount > 0) { + Text( + text = unreadCount.toString(), + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .padding(horizontal = 6.dp, vertical = 3.dp), + color = MaterialTheme.colors.onPrimary, + style = MaterialTheme.typography.caption, + ) + } + } + } + } + } + } +} + +@PreviewLightDark +@Composable +private fun ContactItemPreview() { + AppTheme { + ContactItem( + contact = Contact( + contactKey = "0^all", + shortName = stringResource(R.string.some_username), + longName = stringResource(R.string.unknown_username), + lastMessageTime = "3 minutes ago", + lastMessageText = stringResource(R.string.sample_message), + unreadCount = 2, + messageCount = 10, + isMuted = true, + ), + selected = false, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt new file mode 100644 index 000000000..af9762b89 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -0,0 +1,229 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.R +import com.geeksville.mesh.model.Contact +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.theme.AppTheme +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import java.util.concurrent.TimeUnit + +@AndroidEntryPoint +class ContactsFragment : ScreenFragment("Messages"), Logging { + + private val actionModeCallback: ActionModeCallback = ActionModeCallback() + private var actionMode: ActionMode? = null + private val model: UIViewModel by activityViewModels() + + private val contacts get() = model.contactList.value + private val selectedList = emptyList().toMutableStateList() + + private val selectedContacts get() = contacts.filter { it.contactKey in selectedList } + private val isAllMuted get() = selectedContacts.all { it.isMuted } + private val selectedCount get() = selectedContacts.sumOf { it.messageCount } + + private fun onClick(contact: Contact) { + if (actionMode != null) { + onLongClick(contact) + } else { + debug("calling MessagesFragment filter:${contact.contactKey}") + parentFragmentManager.navigateToMessages(contact.contactKey, contact.longName) + } + } + + private fun onLongClick(contact: Contact) { + if (actionMode == null) { + actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) + } + + selectedList.apply { + if (!remove(contact.contactKey)) add(contact.contactKey) + } + if (selectedList.isEmpty()) { + // finish action mode when no items selected + actionMode?.finish() + } else { + actionMode?.invalidate() + } + } + + override fun onPause() { + actionMode?.finish() + super.onPause() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val contacts by model.contactList.collectAsStateWithLifecycle() + + AppTheme { + ContactListView( + contacts = contacts, + selectedList = selectedList, + onClick = ::onClick, + onLongClick = ::onLongClick, + ) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + actionMode?.finish() + actionMode = null + } + + private inner class ActionModeCallback : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.menu_messages, menu) + menu.findItem(R.id.resendButton).isVisible = false + mode.title = "1" + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.title = selectedList.size.toString() + menu.findItem(R.id.muteButton).setIcon( + if (isAllMuted) { + R.drawable.ic_twotone_volume_up_24 + } else { + R.drawable.ic_twotone_volume_off_24 + } + ) + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.muteButton -> if (isAllMuted) { + model.setMuteUntil(selectedList.toList(), 0L) + mode.finish() + } else { + var muteUntil: Long = Long.MAX_VALUE + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mute_notifications) + .setSingleChoiceItems( + setOf( + R.string.mute_8_hours, + R.string.mute_1_week, + R.string.mute_always, + ).map(::getString).toTypedArray(), + 2 + ) { _, which -> + muteUntil = when (which) { + 0 -> System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8) + 1 -> System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7) + else -> Long.MAX_VALUE // always + } + } + .setPositiveButton(getString(R.string.okay)) { _, _ -> + debug("User clicked muteButton") + model.setMuteUntil(selectedList.toList(), muteUntil) + mode.finish() + } + .setNeutralButton(R.string.cancel) { _, _ -> + } + .show() + } + + R.id.deleteButton -> { + val deleteMessagesString = resources.getQuantityString( + R.plurals.delete_messages, + selectedCount, + selectedCount + ) + MaterialAlertDialogBuilder(requireContext()) + .setMessage(deleteMessagesString) + .setPositiveButton(getString(R.string.delete)) { _, _ -> + debug("User clicked deleteButton") + model.deleteContacts(selectedList.toList()) + mode.finish() + } + .setNeutralButton(R.string.cancel) { _, _ -> + } + .show() + } + R.id.selectAllButton -> { + // if all selected -> unselect all + if (selectedList.size == contacts.size) { + selectedList.clear() + mode.finish() + } else { + // else --> select all + selectedList.clear() + selectedList.addAll(contacts.map { it.contactKey }) + actionMode?.title = contacts.size.toString() + } + } + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + selectedList.clear() + actionMode = null + } + } +} + +@Composable +fun ContactListView( + contacts: List, + selectedList: List, + onClick: (Contact) -> Unit, + onLongClick: (Contact) -> Unit, +) { + val haptics = LocalHapticFeedback.current + LazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(6.dp), + ) { + items(contacts, key = { it.contactKey }) { contact -> + val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } } + + ContactItem( + contact = contact, + selected = selected, + onClick = { onClick(contact) }, + onLongClick = { + onLongClick(contact) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt new file mode 100644 index 000000000..c14a31e7d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/DebugFragment.kt @@ -0,0 +1,270 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.databinding.FragmentDebugBinding +import com.geeksville.mesh.model.DebugViewModel +import com.geeksville.mesh.ui.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint +import java.text.DateFormat +import java.util.Locale + +@AndroidEntryPoint +class DebugFragment : Fragment() { + + private var _binding: FragmentDebugBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val binding get() = _binding!! + + private val model: DebugViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDebugBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.clearButton.setOnClickListener { + model.deleteAllLogs() + } + + binding.closeButton.setOnClickListener { + parentFragmentManager.popBackStack() + } + + binding.debugListView.setContent { + val listState = rememberLazyListState() + val logs by model.meshLog.collectAsStateWithLifecycle() + + val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } } + if (shouldAutoScroll) { + LaunchedEffect(logs) { + if (!listState.isScrollInProgress) { + listState.scrollToItem(0) + } + } + } + + AppTheme { + SelectionContainer { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items(logs, key = { it.uuid }) { log -> DebugItem(annotateMeshLog(log)) } + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** + * Transform the input [MeshLog] by enhancing the raw message with annotations. + */ + private fun annotateMeshLog(meshLog: MeshLog): MeshLog { + val annotated = when (meshLog.message_type) { + "Packet" -> { + meshLog.meshPacket?.let { packet -> + annotateRawMessage(meshLog.raw_message, packet.from, packet.to) + } + } + + "NodeInfo" -> { + meshLog.nodeInfo?.let { nodeInfo -> + annotateRawMessage(meshLog.raw_message, nodeInfo.num) + } + } + + "MyNodeInfo" -> { + meshLog.myNodeInfo?.let { nodeInfo -> + annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) + } + } + + else -> null + } + return if (annotated == null) { + meshLog + } else { + meshLog.copy(raw_message = annotated) + } + } + + /** + * Annotate the raw message string with the node IDs provided, in hex, if they are present. + */ + private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String { + val msg = StringBuilder(rawMessage) + var mutated = false + nodeIds.forEach { nodeId -> + mutated = mutated or msg.annotateNodeId(nodeId) + } + return if (mutated) { + return msg.toString() + } else { + rawMessage + } + } + + /** + * Look for a single node ID integer in the string and annotate it with the hex equivalent + * if found. + */ + private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean { + val nodeIdStr = nodeId.toUInt().toString() + indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx -> + insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})") + return true + } + return false + } + + private fun Int.asNodeId(): String { + return "!%08x".format(Locale.getDefault(), this) + } +} + +private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) + +@Composable +internal fun DebugItem(log: MeshLog) { + val timeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + elevation = 4.dp, + shape = RoundedCornerShape(12.dp), + ) { + Surface { + Column( + modifier = Modifier.padding(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = log.message_type, + modifier = Modifier.weight(1f), + style = TextStyle(fontWeight = FontWeight.Bold), + ) + Icon( + painterResource(R.drawable.cloud_download_outline_24), + contentDescription = null, + tint = Color.Gray.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = timeFormat.format(log.received_date), + style = TextStyle(fontWeight = FontWeight.Bold), + ) + } + + val style = SpanStyle( + color = colorResource(id = R.color.colorAnnotation), + fontStyle = FontStyle.Italic, + ) + val annotatedString = buildAnnotatedString { + append(log.raw_message) + REGEX_ANNOTATED_NODE_ID.findAll(log.raw_message).toList().reversed().forEach { + addStyle(style = style, start = it.range.first, end = it.range.last + 1) + } + } + + Text( + text = annotatedString, + softWrap = false, + style = TextStyle( + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + ) + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun DebugScreenPreview() { + AppTheme { + DebugItem( + MeshLog( + uuid = "", + message_type = "NodeInfo", + received_date = 1601251258000L, + raw_message = "from: 2885173132\n" + + "decoded {\n" + + " position {\n" + + " altitude: 60\n" + + " battery_level: 81\n" + + " latitude_i: 411111136\n" + + " longitude_i: -711111805\n" + + " time: 1600390966\n" + + " }\n" + + "}\n" + + "hop_limit: 3\n" + + "id: 1737414295\n" + + "rx_snr: 9.5\n" + + "rx_time: 316400569\n" + + "to: -1409790708", + ) + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/LastHeardInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/LastHeardInfo.kt new file mode 100644 index 000000000..a9200362b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/LastHeardInfo.kt @@ -0,0 +1,55 @@ +package com.geeksville.mesh.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.formatAgo + +@Composable +fun LastHeardInfo( + modifier: Modifier = Modifier, + lastHeard: Int, + currentTimeMillis: Long, +) { + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + modifier = Modifier.height(18.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_antenna_24), + contentDescription = null, + tint = MaterialTheme.colors.onSurface, + ) + Text( + text = formatAgo(lastHeard, currentTimeMillis), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +@PreviewLightDark +@Composable +fun LastHeardInfoPreview() { + AppTheme { + LastHeardInfo( + lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600, + currentTimeMillis = System.currentTimeMillis() + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt b/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt new file mode 100644 index 000000000..4b6250fa9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/LinkedCoordinates.kt @@ -0,0 +1,106 @@ +package com.geeksville.mesh.ui + +import android.content.ActivityNotFoundException +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat +import com.geeksville.mesh.android.BuildUtils.debug +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.ui.theme.HyperlinkBlue +import com.geeksville.mesh.util.GPSFormat +import java.net.URLEncoder + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LinkedCoordinates( + modifier: Modifier = Modifier, + latitude: Double, + longitude: Double, + format: Int, + nodeName: String, +) { + val uriHandler = LocalUriHandler.current + val style = SpanStyle( + color = HyperlinkBlue, + fontSize = MaterialTheme.typography.button.fontSize, + textDecoration = TextDecoration.Underline + ) + val annotatedString = buildAnnotatedString { + pushStringAnnotation( + tag = "gps", + // URI scheme is defined at: + // https://developer.android.com/guide/components/intents-common#Maps + annotation = "geo:0,0?q=$latitude,$longitude&z=17&label=${ + URLEncoder.encode(nodeName, "utf-8") + }" + ) + withStyle(style = style) { + val gpsString = when (format) { + GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude) + GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude) + GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude) + GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude) + else -> GPSFormat.toDEC(latitude, longitude) + } + append(gpsString) + } + pop() + } + val clipboardManager: ClipboardManager = LocalClipboardManager.current + Text( + modifier = modifier.combinedClickable( + onClick = { + annotatedString.getStringAnnotations( + tag = "gps", + start = 0, + end = annotatedString.length + ).firstOrNull()?.let { + try { + uriHandler.openUri(it.item) + } catch (ex: ActivityNotFoundException) { + debug("No application found: $ex") + } + } + }, + onLongClick = { + clipboardManager.setText(annotatedString) + debug("Copied to clipboard") + } + ), + text = annotatedString + ) +} + +@PreviewLightDark +@Composable +fun LinkedCoordinatesPreview( + @PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int +) { + AppTheme { + LinkedCoordinates( + latitude = 37.7749, + longitude = -122.4194, + format = format, + nodeName = "Test Node Name" + ) + } +} + +class GPSFormatPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(0, 1, 2) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt new file mode 100644 index 000000000..001455a37 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt @@ -0,0 +1,167 @@ +package com.geeksville.mesh.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentColor +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Cloud +import androidx.compose.material.icons.twotone.CloudDone +import androidx.compose.material.icons.twotone.CloudOff +import androidx.compose.material.icons.twotone.CloudUpload +import androidx.compose.material.icons.twotone.HowToReg +import androidx.compose.material.icons.twotone.Warning +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.components.AutoLinkText +import com.geeksville.mesh.ui.theme.AppTheme + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +internal fun MessageItem( + shortName: String?, + messageText: String?, + messageTime: String, + messageStatus: MessageStatus?, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + onChipClick: () -> Unit = {}, + onStatusClick: () -> Unit = {}, +) { + val fromLocal = shortName == null + val messageColor = if (fromLocal) R.color.colorMyMsg else R.color.colorMsg + val (topStart, topEnd) = if (fromLocal) 12.dp to 4.dp else 4.dp to 12.dp + val messageModifier = if (fromLocal) { + Modifier.padding(start = 48.dp, top = 8.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier.padding(start = 8.dp, top = 8.dp, end = 48.dp, bottom = 6.dp) + } + + Card( + modifier = Modifier + .background(color = if (selected) Color.Gray else MaterialTheme.colors.background) + .fillMaxWidth() + .then(messageModifier), + elevation = 4.dp, + shape = RoundedCornerShape(topStart, topEnd, 12.dp, 12.dp), + ) { + Surface( + modifier = modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + color = colorResource(id = messageColor), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (shortName != null) { + Chip( + onClick = onChipClick, + modifier = Modifier + .padding(end = 8.dp) + .width(72.dp), + ) { + Text( + text = shortName, + modifier = Modifier.fillMaxWidth(), + fontSize = MaterialTheme.typography.button.fontSize, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + } + } + Column( + modifier = Modifier.padding(top = 8.dp), + ) { +// Text( +// text = longName ?: stringResource(id = R.string.unknown_username), +// color = MaterialTheme.colors.onSurface, +// fontSize = MaterialTheme.typography.button.fontSize, +// ) + AutoLinkText( + text = messageText.orEmpty(), + style = LocalTextStyle.current.copy( + color = LocalContentColor.current, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = messageTime, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.caption.fontSize, + ) + AnimatedVisibility(visible = fromLocal) { + Icon( + imageVector = when (messageStatus) { + MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg + MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload + MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone + MessageStatus.ENROUTE -> Icons.TwoTone.Cloud + MessageStatus.ERROR -> Icons.TwoTone.CloudOff + else -> Icons.TwoTone.Warning + }, + contentDescription = stringResource(R.string.message_delivery_status), + modifier = Modifier + .padding(start = 8.dp) + .clickable { onStatusClick() }, + ) + } + } + } + } + } + } +} + +@PreviewLightDark +@Composable +private fun MessageItemPreview() { + AppTheme { + MessageItem( + shortName = stringResource(R.string.some_username), + // longName = stringResource(R.string.unknown_username), + messageText = stringResource(R.string.sample_message), + messageTime = "10:00", + messageStatus = MessageStatus.DELIVERED, + selected = false, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt new file mode 100644 index 000000000..42c65cf60 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt @@ -0,0 +1,106 @@ +package com.geeksville.mesh.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.model.Message +import com.geeksville.mesh.ui.components.SimpleAlertDialog +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce + +@Composable +internal fun MessageListView( + messages: List, + selectedList: List, + onClick: (Message) -> Unit, + onLongClick: (Message) -> Unit, + onChipClick: (Message) -> Unit, + onUnreadChanged: (Long) -> Unit, +) { + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0) + ) + AutoScrollToBottom(listState, messages) + UpdateUnreadCount(listState, messages, onUnreadChanged) + + var showStatusDialog by remember { mutableStateOf(null) } + if (showStatusDialog != null) { + val msg = showStatusDialog ?: return + val (title, text) = msg.getStatusStringRes() + SimpleAlertDialog(title = title, text = text) { showStatusDialog = null } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + // contentPadding = PaddingValues(8.dp) + ) { + items(messages, key = { it.uuid }) { msg -> + val selected by remember { derivedStateOf { selectedList.contains(msg) } } + + MessageItem( + shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL }, + messageText = msg.text, + messageTime = msg.time, + messageStatus = msg.status, + selected = selected, + onClick = { onClick(msg) }, + onLongClick = { onLongClick(msg) }, + onChipClick = { onChipClick(msg) }, + onStatusClick = { showStatusDialog = msg } + ) + } + } +} + +@Composable +private fun AutoScrollToBottom( + listState: LazyListState, + list: List, + itemThreshold: Int = 3, +) = with(listState) { + val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } } + if (shouldAutoScroll) { + LaunchedEffect(list) { + if (!isScrollInProgress) { + scrollToItem(0) + } + } + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun UpdateUnreadCount( + listState: LazyListState, + messages: List, + onUnreadChanged: (Long) -> Unit, +) { + val unreadIndex by remember { derivedStateOf { messages.indexOfLast { !it.read } } } + val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } + + if (unreadIndex != -1 && firstVisibleItemIndex != -1 && firstVisibleItemIndex <= unreadIndex) { + LaunchedEffect(firstVisibleItemIndex, unreadIndex) { + snapshotFlow { listState.firstVisibleItemIndex } + .debounce(timeoutMillis = 500L) + .collectLatest { index -> + val lastVisibleItem = messages[index] + onUnreadChanged(lastVisibleItem.receivedTime) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt new file mode 100644 index 000000000..8e4843a06 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -0,0 +1,291 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.compose.runtime.getValue +import androidx.compose.runtime.toMutableStateList +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.core.view.allViews +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.asLiveData +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.databinding.MessagesFragmentBinding +import com.geeksville.mesh.model.Message +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.getChannel +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.Utf8ByteLengthFilter +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +internal fun FragmentManager.navigateToMessages(contactKey: String, contactName: String) { + val messagesFragment = MessagesFragment().apply { + arguments = bundleOf("contactKey" to contactKey, "contactName" to contactName) + } + beginTransaction() + .add(R.id.mainActivityLayout, messagesFragment) + .addToBackStack(null) + .commit() +} + +@AndroidEntryPoint +class MessagesFragment : Fragment(), Logging { + + private val actionModeCallback: ActionModeCallback = ActionModeCallback() + private var actionMode: ActionMode? = null + private var _binding: MessagesFragmentBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val binding get() = _binding!! + + private val model: UIViewModel by activityViewModels() + + private lateinit var contactKey: String + + private val selectedList = emptyList().toMutableStateList() + + private fun onClick(message: Message) { + if (actionMode != null) { + onLongClick(message) + } + } + + private fun onLongClick(message: Message) { + if (actionMode == null) { + actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) + } + selectedList.apply { + if (contains(message)) remove(message) else add(message) + } + if (selectedList.isEmpty()) { + // finish action mode when no items selected + actionMode?.finish() + } else { + // show total items selected on action mode title + actionMode?.title = selectedList.size.toString() + } + } + + override fun onPause() { + actionMode?.finish() + super.onPause() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = MessagesFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + contactKey = arguments?.getString("contactKey").toString() + val contactName = arguments?.getString("contactName").toString() + binding.toolbar.title = contactName + val channelNumber = contactKey[0].digitToIntOrNull() + if (channelNumber == DataPacket.PKC_CHANNEL_INDEX) { + binding.toolbar.title = "$contactName🔒" + } else if (channelNumber != null && contactKey.substring(1) != DataPacket.ID_BROADCAST) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + model.channels.collect { channels -> + val channelName = + channels.getChannel(channelNumber)?.name ?: "Unknown Channel" + val subtitle = "(ch: $channelNumber - $channelName)" + binding.toolbar.subtitle = subtitle + } + } + } + } + + fun sendMessageInputText() { + val str = binding.messageInputText.text.toString().trim() + if (str.isNotEmpty()) { + model.sendMessage(str, contactKey) + } + binding.messageInputText.setText("") // blow away the string the user just entered + // requireActivity().hideKeyboard() + } + + binding.sendButton.setOnClickListener { + debug("User clicked sendButton") + sendMessageInputText() + } + + // max payload length should be 237 bytes but anything over 235 bytes crashes the radio + binding.messageInputText.filters += Utf8ByteLengthFilter(234) + + binding.messageListView.setContent { + val messages by model.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf()) + + AppTheme { + if (messages.isNotEmpty()) { + MessageListView( + messages = messages, + selectedList = selectedList, + onClick = ::onClick, + onLongClick = ::onLongClick, + onChipClick = ::openNodeInfo, + onUnreadChanged = { model.clearUnreadCount(contactKey, it) }, + ) + } + } + } + + // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages + model.connectionState.observe(viewLifecycleOwner) { + // If we don't know our node ID and we are offline don't let user try to send + val isConnected = model.isConnected() + binding.textInputLayout.isEnabled = isConnected + binding.sendButton.isEnabled = isConnected + for (subView: View in binding.quickChatLayout.allViews) { + if (subView is Button) { + subView.isEnabled = isConnected + } + } + } + + model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions -> + actions?.let { + // This seems kinda hacky it might be better to replace with a recycler view + binding.quickChatLayout.removeAllViews() + for (action in actions) { + val button = Button(context) + button.text = action.name + button.isEnabled = model.isConnected() + if (action.mode == QuickChatAction.Mode.Instant) { + button.backgroundTintList = + ContextCompat.getColorStateList(requireActivity(), R.color.colorMyMsg) + } + button.setOnClickListener { + if (action.mode == QuickChatAction.Mode.Append) { + val originalText = binding.messageInputText.text ?: "" + val needsSpace = + !originalText.endsWith(' ') && originalText.isNotEmpty() + val newText = buildString { + append(originalText) + if (needsSpace) append(' ') + append(action.message) + } + binding.messageInputText.setText(newText) + binding.messageInputText.setSelection(newText.length) + } else { + model.sendMessage(action.message, contactKey) + } + } + binding.quickChatLayout.addView(button) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + actionMode?.finish() + actionMode = null + _binding = null + } + + private inner class ActionModeCallback : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.menu_messages, menu) + menu.findItem(R.id.muteButton).isVisible = false + mode.title = "1" + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + return false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + when (item.itemId) { + R.id.deleteButton -> { + val deleteMessagesString = resources.getQuantityString( + R.plurals.delete_messages, + selectedList.size, + selectedList.size + ) + MaterialAlertDialogBuilder(requireContext()) + .setMessage(deleteMessagesString) + .setPositiveButton(getString(R.string.delete)) { _, _ -> + debug("User clicked deleteButton") + model.deleteMessages(selectedList.map { it.uuid }) + mode.finish() + } + .setNeutralButton(R.string.cancel) { _, _ -> + } + .show() + } + R.id.selectAllButton -> lifecycleScope.launch { + model.getMessagesFrom(contactKey).firstOrNull()?.let { messages -> + if (selectedList.size == messages.size) { + // if all selected -> unselect all + selectedList.clear() + mode.finish() + } else { + // else --> select all + selectedList.clear() + selectedList.addAll(messages) + } + actionMode?.title = selectedList.size.toString() + } + } + + R.id.resendButton -> lifecycleScope.launch { + debug("User clicked resendButton") + var resendText = "" + selectedList.forEach { + resendText = resendText + it.text + System.lineSeparator() + } + if (resendText != "") { + resendText = resendText.substring(0, resendText.length - 1) + } + binding.messageInputText.setText(resendText) + mode.finish() + } + } + return true + } + + override fun onDestroyActionMode(mode: ActionMode) { + selectedList.clear() + actionMode = null + } + } + + private fun openNodeInfo(msg: Message) = lifecycleScope.launch { + model.nodeList.firstOrNull()?.find { it.user.id == msg.user.id }?.let { node -> + parentFragmentManager.popBackStack() + model.focusUserNode(node) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt new file mode 100644 index 000000000..7a16673fc --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -0,0 +1,351 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Forward +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.DataUsage +import androidx.compose.material.icons.filled.DisplaySettings +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.PermScanWifi +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.SettingsRemote +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.DeviceMetricsScreen +import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen +import com.geeksville.mesh.ui.components.NodeMapScreen +import com.geeksville.mesh.ui.components.PositionLogScreen +import com.geeksville.mesh.ui.components.SignalMetricsScreen +import com.geeksville.mesh.ui.components.TracerouteLogScreen +import com.geeksville.mesh.ui.components.config.AmbientLightingConfigScreen +import com.geeksville.mesh.ui.components.config.AudioConfigScreen +import com.geeksville.mesh.ui.components.config.BluetoothConfigScreen +import com.geeksville.mesh.ui.components.config.CannedMessageConfigScreen +import com.geeksville.mesh.ui.components.config.ChannelConfigScreen +import com.geeksville.mesh.ui.components.config.DetectionSensorConfigScreen +import com.geeksville.mesh.ui.components.config.DeviceConfigScreen +import com.geeksville.mesh.ui.components.config.DisplayConfigScreen +import com.geeksville.mesh.ui.components.config.ExternalNotificationConfigScreen +import com.geeksville.mesh.ui.components.config.LoRaConfigScreen +import com.geeksville.mesh.ui.components.config.MQTTConfigScreen +import com.geeksville.mesh.ui.components.config.NeighborInfoConfigScreen +import com.geeksville.mesh.ui.components.config.NetworkConfigScreen +import com.geeksville.mesh.ui.components.config.PaxcounterConfigScreen +import com.geeksville.mesh.ui.components.config.PositionConfigScreen +import com.geeksville.mesh.ui.components.config.PowerConfigScreen +import com.geeksville.mesh.ui.components.config.RangeTestConfigScreen +import com.geeksville.mesh.ui.components.config.RemoteHardwareConfigScreen +import com.geeksville.mesh.ui.components.config.SecurityConfigScreen +import com.geeksville.mesh.ui.components.config.SerialConfigScreen +import com.geeksville.mesh.ui.components.config.StoreForwardConfigScreen +import com.geeksville.mesh.ui.components.config.TelemetryConfigScreen +import com.geeksville.mesh.ui.components.config.UserConfigScreen +import com.google.accompanist.themeadapter.appcompat.AppCompatTheme +import dagger.hilt.android.AndroidEntryPoint + +internal fun FragmentManager.navigateToNavGraph( + destNum: Int? = null, + startDestination: String = "RadioConfig", +) { + val radioConfigFragment = NavGraphFragment().apply { + arguments = bundleOf("destNum" to destNum, "startDestination" to startDestination) + } + beginTransaction() + .replace(R.id.mainActivityLayout, radioConfigFragment) + .addToBackStack(null) + .commit() +} + +@AndroidEntryPoint +class NavGraphFragment : ScreenFragment("NavGraph"), Logging { + + private val model: RadioConfigViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val destNum = arguments?.getInt("destNum") + val startDestination = arguments?.getString("startDestination") ?: "RadioConfig" + model.setDestNum(destNum) + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) + setContent { + val node by model.destNode.collectAsStateWithLifecycle() + + AppCompatTheme { + val navController: NavHostController = rememberNavController() + Scaffold( + topBar = { + MeshAppBar( + currentScreen = node?.user?.longName + ?: stringResource(R.string.unknown_username), + canNavigateBack = true, + navigateUp = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + parentFragmentManager.popBackStack() + } + }, + ) + } + ) { innerPadding -> + NavGraph( + node = node, + viewModel = model, + navController = navController, + startDestination = startDestination, + modifier = Modifier.padding(innerPadding), + ) + } + } + } + } + } +} + +enum class AdminRoute(@StringRes val title: Int) { + REBOOT(R.string.reboot), + SHUTDOWN(R.string.shutdown), + FACTORY_RESET(R.string.factory_reset), + NODEDB_RESET(R.string.nodedb_reset), +} + +// Config (configType = AdminProtos.AdminMessage.ConfigType) +enum class ConfigRoute(val title: String, val icon: ImageVector?, val configType: Int = 0) { + USER("User", Icons.Default.Person, 0), + CHANNELS("Channels", Icons.AutoMirrored.Default.List, 0), + DEVICE("Device", Icons.Default.Router, 0), + POSITION("Position", Icons.Default.LocationOn, 1), + POWER("Power", Icons.Default.Power, 2), + NETWORK("Network", Icons.Default.Wifi, 3), + DISPLAY("Display", Icons.Default.DisplaySettings, 4), + LORA("LoRa", Icons.Default.CellTower, 5), + BLUETOOTH("Bluetooth", Icons.Default.Bluetooth, 6), + SECURITY("Security", Icons.Default.Security, configType = 7), +} + +// ModuleConfig (configType = AdminProtos.AdminMessage.ModuleConfigType) +enum class ModuleRoute(val title: String, val icon: ImageVector?, val configType: Int = 0) { + MQTT("MQTT", Icons.Default.Cloud, 0), + SERIAL("Serial", Icons.Default.Usb, 1), + EXTERNAL_NOTIFICATION("External Notification", Icons.Default.Notifications, 2), + STORE_FORWARD("Store & Forward", Icons.AutoMirrored.Default.Forward, 3), + RANGE_TEST("Range Test", Icons.Default.Speed, 4), + TELEMETRY("Telemetry", Icons.Default.DataUsage, 5), + CANNED_MESSAGE("Canned Message", Icons.AutoMirrored.Default.Message, 6), + AUDIO("Audio", Icons.AutoMirrored.Default.VolumeUp, 7), + REMOTE_HARDWARE("Remote Hardware", Icons.Default.SettingsRemote, 8), + NEIGHBOR_INFO("Neighbor Info", Icons.Default.People, 9), + AMBIENT_LIGHTING("Ambient Lighting", Icons.Default.LightMode, 10), + DETECTION_SENSOR("Detection Sensor", Icons.Default.Sensors, 11), + PAXCOUNTER("Paxcounter", Icons.Default.PermScanWifi, 12), +} + +/** + * Generic sealed class defines each possible state of a response. + */ +sealed class ResponseState { + data object Empty : ResponseState() + data class Loading(var total: Int = 1, var completed: Int = 0) : ResponseState() + data class Success(val result: T) : ResponseState() + data class Error(val error: String) : ResponseState() + + fun isWaiting() = this !is Empty +} + +@Composable +private fun MeshAppBar( + currentScreen: String, + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + title = { Text(currentScreen) }, + modifier = modifier, + navigationIcon = { + if (canNavigateBack) { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.navigate_back), + ) + } + } + } + ) +} + +@Suppress("LongMethod") +@Composable +fun NavGraph( + node: NodeEntity?, + viewModel: RadioConfigViewModel = hiltViewModel(), + navController: NavHostController = rememberNavController(), + startDestination: String, + modifier: Modifier = Modifier, +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + ) { + composable("NodeDetails") { + NodeDetailScreen( + node = node, + ) { navController.navigate(route = it) } + } + composable("DeviceMetrics") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + DeviceMetricsScreen(hiltViewModel(parentEntry)) + } + composable("NodeMap") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + NodeMapScreen(hiltViewModel(parentEntry)) + } + composable("PositionLog") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + PositionLogScreen(hiltViewModel(parentEntry)) + } + composable("EnvironmentMetrics") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + EnvironmentMetricsScreen(hiltViewModel(parentEntry)) + } + composable("SignalMetrics") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + SignalMetricsScreen(hiltViewModel(parentEntry)) + } + composable("TracerouteList") { + val parentEntry = remember { navController.getBackStackEntry("NodeDetails") } + TracerouteLogScreen(hiltViewModel(parentEntry)) + } + composable("RadioConfig") { + RadioConfigScreen( + node = node, + viewModel = viewModel, + ) { navController.navigate(route = it) } + } + composable(ConfigRoute.USER.name) { + UserConfigScreen(viewModel) + } + composable(ConfigRoute.CHANNELS.name) { + ChannelConfigScreen(viewModel) + } + composable(ConfigRoute.DEVICE.name) { + DeviceConfigScreen(viewModel) + } + composable(ConfigRoute.POSITION.name) { + PositionConfigScreen(viewModel) + } + composable(ConfigRoute.POWER.name) { + PowerConfigScreen(viewModel) + } + composable(ConfigRoute.NETWORK.name) { + NetworkConfigScreen(viewModel) + } + composable(ConfigRoute.DISPLAY.name) { + DisplayConfigScreen(viewModel) + } + composable(ConfigRoute.LORA.name) { + LoRaConfigScreen(viewModel) + } + composable(ConfigRoute.BLUETOOTH.name) { + BluetoothConfigScreen(viewModel) + } + composable(ConfigRoute.SECURITY.name) { + SecurityConfigScreen(viewModel) + } + composable(ModuleRoute.MQTT.name) { + MQTTConfigScreen(viewModel) + } + composable(ModuleRoute.SERIAL.name) { + SerialConfigScreen(viewModel) + } + composable(ModuleRoute.EXTERNAL_NOTIFICATION.name) { + ExternalNotificationConfigScreen(viewModel) + } + composable(ModuleRoute.STORE_FORWARD.name) { + StoreForwardConfigScreen(viewModel) + } + composable(ModuleRoute.RANGE_TEST.name) { + RangeTestConfigScreen(viewModel) + } + composable(ModuleRoute.TELEMETRY.name) { + TelemetryConfigScreen(viewModel) + } + composable(ModuleRoute.CANNED_MESSAGE.name) { + CannedMessageConfigScreen(viewModel) + } + composable(ModuleRoute.AUDIO.name) { + AudioConfigScreen(viewModel) + } + composable(ModuleRoute.REMOTE_HARDWARE.name) { + RemoteHardwareConfigScreen(viewModel) + } + composable(ModuleRoute.NEIGHBOR_INFO.name) { + NeighborInfoConfigScreen(viewModel) + } + composable(ModuleRoute.AMBIENT_LIGHTING.name) { + AmbientLightingConfigScreen(viewModel) + } + composable(ModuleRoute.DETECTION_SENSOR.name) { + DetectionSensorConfigScreen(viewModel) + } + composable(ModuleRoute.PAXCOUNTER.name) { + PaxcounterConfigScreen(viewModel) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt new file mode 100644 index 000000000..d74448628 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -0,0 +1,490 @@ +@file:Suppress("TooManyFunctions") + +package com.geeksville.mesh.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.BlurOn +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ChargingStation +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SignalCellularAlt +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.Work +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.MetricsState +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.formatAgo +import java.util.concurrent.TimeUnit +import kotlin.math.ln + +@Composable +fun NodeDetailScreen( + node: NodeEntity?, + viewModel: MetricsViewModel = hiltViewModel(), + modifier: Modifier = Modifier, + onNavigate: (String) -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + if (node != null) { + LaunchedEffect(node.num) { + viewModel.setSelectedNode(node.num) + } + NodeDetailList( + node = node, + metricsState = state, + onNavigate = onNavigate, + modifier = modifier, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } +} + +@Composable +private fun NodeDetailList( + node: NodeEntity, + metricsState: MetricsState, + modifier: Modifier = Modifier, + onNavigate: (String) -> Unit = {}, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { + PreferenceCategory("Details") { + NodeDetailsContent(node) + } + } + + if (node.hasEnvironmentMetrics) { + item { + PreferenceCategory("Environment") + EnvironmentMetrics(node, metricsState.isFahrenheit) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + if (node.hasPowerMetrics) { + item { + PreferenceCategory("Power") + PowerMetrics(node) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + item { + PreferenceCategory(stringResource(id = R.string.logs)) + LogNavigationList(metricsState, onNavigate) + } + + if (!metricsState.isManaged) { + item { + PreferenceCategory(stringResource(id = R.string.administration)) + NavCard( + title = stringResource(id = R.string.remote_admin), + icon = Icons.Default.Settings, + enabled = true + ) { + onNavigate("RadioConfig") + } + } + } + } +} + +@Composable +private fun NodeDetailRow(label: String, icon: ImageVector, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(label) + Spacer(modifier = Modifier.weight(1f)) + Text(value) + } +} + +@Composable +private fun NodeDetailsContent(node: NodeEntity) { + if (node.mismatchKey) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.KeyOff, + contentDescription = stringResource(id = R.string.encryption_error), + tint = Color.Red, + ) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = stringResource(id = R.string.encryption_error), + style = MaterialTheme.typography.h6.copy(color = Color.Red) + ) + Text( + text = stringResource(id = R.string.encryption_error_text), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } + } + NodeDetailRow( + label = "Node Number", + icon = Icons.Default.Numbers, + value = node.num.toUInt().toString() + ) + NodeDetailRow( + label = "User Id", + icon = Icons.Default.Person, + value = node.user.id + ) + NodeDetailRow( + label = "Role", + icon = Icons.Default.Work, + value = node.user.role.name + ) + NodeDetailRow( + label = "Hardware", + icon = Icons.Default.Router, + value = node.user.hwModel.name + ) + if (node.deviceMetrics.uptimeSeconds > 0) { + NodeDetailRow( + label = "Uptime", + icon = Icons.Default.CheckCircle, + value = formatUptime(node.deviceMetrics.uptimeSeconds) + ) + } + NodeDetailRow( + label = "Last heard", + icon = Icons.Default.History, + value = formatAgo(node.lastHeard) + ) +} + +@Composable +fun LogNavigationList(state: MetricsState, onNavigate: (String) -> Unit) { + NavCard( + title = stringResource(R.string.device_metrics_log), + icon = Icons.Default.ChargingStation, + enabled = state.hasDeviceMetrics() + ) { + onNavigate("DeviceMetrics") + } + + NavCard( + title = stringResource(R.string.node_map), + icon = Icons.Default.Map, + enabled = state.hasPositionLogs() + ) { + onNavigate("NodeMap") + } + + NavCard( + title = stringResource(R.string.position_log), + icon = Icons.Default.LocationOn, + enabled = state.hasPositionLogs() + ) { + onNavigate("PositionLog") + } + + NavCard( + title = stringResource(R.string.env_metrics_log), + icon = Icons.Default.Thermostat, + enabled = state.hasEnvironmentMetrics() + ) { + onNavigate("EnvironmentMetrics") + } + + NavCard( + title = stringResource(R.string.sig_metrics_log), + icon = Icons.Default.SignalCellularAlt, + enabled = state.hasSignalMetrics() + ) { + onNavigate("SignalMetrics") + } + + NavCard( + title = stringResource(R.string.traceroute_log), + icon = Icons.Default.Route, + enabled = state.hasTracerouteLogs() + ) { + onNavigate("TracerouteList") + } +} + +@Composable +private fun InfoCard( + icon: ImageVector, + text: String, + value: String, +) { + Card( + shape = RoundedCornerShape(12.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = 4.dp, + modifier = Modifier + .padding(4.dp) + .widthIn(min = 100.dp, max = 150.dp) + .heightIn(min = 100.dp, max = 150.dp) + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier.size(24.dp), + ) + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.subtitle2 + ) + Text( + text = value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h5 + ) + } + } +} + +private fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong()) + +private fun formatUptime(seconds: Long): String { + val days = TimeUnit.SECONDS.toDays(seconds) + val hours = TimeUnit.SECONDS.toHours(seconds) % TimeUnit.DAYS.toHours(1) + val minutes = TimeUnit.SECONDS.toMinutes(seconds) % TimeUnit.HOURS.toMinutes(1) + val secs = seconds % TimeUnit.MINUTES.toSeconds(1) + + return listOfNotNull( + "${days}d".takeIf { days > 0 }, + "${hours}h".takeIf { hours > 0 }, + "${minutes}m".takeIf { minutes > 0 }, + "${secs}s".takeIf { secs > 0 }, + ).joinToString(" ") +} + +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetrics( + node: NodeEntity, + isFahrenheit: Boolean = false, +) = with(node.environmentMetrics) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + if (temperature != 0f) { + InfoCard( + icon = Icons.Default.Thermostat, + text = "Temperature", + value = temperature.toTempString(isFahrenheit) + ) + } + if (relativeHumidity != 0f) { + InfoCard( + icon = Icons.Default.WaterDrop, + text = "Humidity", + value = "%.0f%%".format(relativeHumidity) + ) + } + if (temperature != 0f && relativeHumidity != 0f) { + val dewPoint = calculateDewPoint(temperature, relativeHumidity) + InfoCard( + icon = ImageVector.vectorResource(R.drawable.ic_outlined_dew_point_24), + text = "Dew Point", + value = dewPoint.toTempString(isFahrenheit) + ) + } + if (barometricPressure != 0f) { + InfoCard( + icon = Icons.Default.Speed, + text = "Pressure", + value = "%.0f".format(barometricPressure) + ) + } + if (gasResistance != 0f) { + InfoCard( + icon = Icons.Default.BlurOn, + text = "Gas Resistance", + value = "%.0f".format(gasResistance) + ) + } + if (voltage != 0f) { + InfoCard( + icon = Icons.Default.Bolt, + text = "Voltage", + value = "%.2fV".format(voltage) + ) + } + if (current != 0f) { + InfoCard( + icon = Icons.Default.Power, + text = "Current", + value = "%.1fmA".format(current) + ) + } + if (iaq != 0) { + InfoCard( + icon = Icons.Default.Air, + text = "IAQ", + value = iaq.toString() + ) + } + } +} + +@Suppress("MagicNumber") +private fun Float.toTempString(isFahrenheit: Boolean) = if (isFahrenheit) { + val fahrenheit = this * 1.8F + 32 + "%.0f°F".format(fahrenheit) +} else { + "%.0f°C".format(this) +} + +// Magnus-Tetens approximation +@Suppress("MagicNumber") +private fun calculateDewPoint(tempCelsius: Float, humidity: Float): Float { + val (a, b) = 17.27f to 237.7f + val alpha = (a * tempCelsius) / (b + tempCelsius) + ln(humidity / 100f) + return (b * alpha) / (a - alpha) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + if (ch1Voltage != 0f) { + InfoCard( + icon = Icons.Default.Bolt, + text = "Channel 1", + value = "%.2fV".format(ch1Voltage) + ) + } + if (ch1Current != 0f) { + InfoCard( + icon = Icons.Default.Power, + text = "Channel 1", + value = "%.1fmA".format(ch1Current) + ) + } + if (ch2Voltage != 0f) { + InfoCard( + icon = Icons.Default.Bolt, + text = "Channel 2", + value = "%.2fV".format(ch2Voltage) + ) + } + if (ch2Current != 0f) { + InfoCard( + icon = Icons.Default.Power, + text = "Channel 2", + value = "%.1fmA".format(ch2Current) + ) + } + if (ch3Voltage != 0f) { + InfoCard( + icon = Icons.Default.Bolt, + text = "Channel 3", + value = "%.2fV".format(ch3Voltage) + ) + } + if (ch3Current != 0f) { + InfoCard( + icon = Icons.Default.Power, + text = "Channel 3", + value = "%.1fmA".format(ch3Current) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NodeDetailsPreview( + @PreviewParameter(NodeEntityPreviewParameterProvider::class) + node: NodeEntity +) { + AppTheme { + NodeDetailList(node, MetricsState.Empty) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt new file mode 100644 index 000000000..46450b789 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -0,0 +1,366 @@ +@file:Suppress( + "LongMethod", + "MagicNumber", + "CyclomaticComplexMethod", +) + +package com.geeksville.mesh.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Chip +import androidx.compose.material.ChipDefaults +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.ui.components.NodeKeyStatusIcon +import com.geeksville.mesh.ui.components.SimpleAlertDialog +import com.geeksville.mesh.ui.compose.ElevationInfo +import com.geeksville.mesh.ui.compose.SatelliteCountInfo +import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.metersIn +import com.geeksville.mesh.util.toDistanceString + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun NodeItem( + thisNode: NodeEntity?, + thatNode: NodeEntity, + gpsFormat: Int, + distanceUnits: Int, + tempInFahrenheit: Boolean, + isIgnored: Boolean = false, + chipClicked: () -> Unit = {}, + blinking: Boolean = false, + expanded: Boolean = false, + currentTimeMillis: Long, +) { + val isUnknownUser = thatNode.isUnknownUser + val unknownShortName = stringResource(id = R.string.unknown_node_short_name) + val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) } + + val isThisNode = thisNode?.num == thatNode.num + val distance = thisNode?.distance(thatNode)?.let { + val system = DisplayConfig.DisplayUnits.forNumber(distanceUnits) + if (it == 0) null else it.toDistanceString(system) + } + val (textColor, nodeColor) = thatNode.colors + + val hwInfoString = when (val hwModel = thatNode.user.hwModel) { + MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name + else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() + } + val roleName = if (isUnknownUser) { + DeviceConfig.Role.UNRECOGNIZED.name + } else { + thatNode.user.role.name + } + val nodeId = thatNode.user.id.ifEmpty { "???" } + + val highlight = Color(0x33FFFFFF) + val bgColor by animateColorAsState( + targetValue = if (blinking) highlight else Color.Transparent, + animationSpec = repeatable( + iterations = 6, + animation = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "blinking node" + ) + + val style = if (isUnknownUser) { + LocalTextStyle.current.copy(fontStyle = FontStyle.Italic) + } else { + LocalTextStyle.current + } + + val (detailsShown, showDetails) = remember { mutableStateOf(expanded) } + + var showEncryptionDialog by remember { mutableStateOf(false) } + if (showEncryptionDialog) { + val (title, text) = when { + thatNode.mismatchKey -> R.string.encryption_error to R.string.encryption_error_text + thatNode.hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text + else -> R.string.encryption_psk to R.string.encryption_psk_text + } + SimpleAlertDialog(title, text) { showEncryptionDialog = false } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .defaultMinSize(minHeight = 80.dp), + elevation = 4.dp, + onClick = { showDetails(!detailsShown) }, + ) { + Surface { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .background(bgColor) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Chip( + modifier = Modifier + .width(IntrinsicSize.Min) + .defaultMinSize(minHeight = 32.dp, minWidth = 72.dp), + colors = ChipDefaults.chipColors( + backgroundColor = Color(nodeColor), + contentColor = Color(textColor) + ), + onClick = { chipClicked() }, + content = { + Text( + modifier = Modifier.fillMaxWidth(), + text = thatNode.user.shortName.ifEmpty { unknownShortName }, + fontWeight = FontWeight.Normal, + fontSize = MaterialTheme.typography.button.fontSize, + textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, + textAlign = TextAlign.Center, + ) + }, + ) + NodeKeyStatusIcon( + hasPKC = thatNode.hasPKC, + mismatchKey = thatNode.mismatchKey, + modifier = Modifier.size(32.dp) + ) { showEncryptionDialog = true } + Text( + modifier = Modifier.weight(1f), + text = longName, + style = style, + textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, + softWrap = true, + ) + + LastHeardInfo( + lastHeard = thatNode.lastHeard, + currentTimeMillis = currentTimeMillis + ) + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (distance != null) { + Text( + text = distance, + fontSize = MaterialTheme.typography.button.fontSize, + ) + } else { + Spacer(modifier = Modifier.width(16.dp)) + } + BatteryInfo( + batteryLevel = thatNode.batteryLevel, + voltage = thatNode.voltage + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + signalInfo( + node = thatNode, + isThisNode = isThisNode + ) + thatNode.validPosition?.let { position -> + val satCount = position.satsInView + if (satCount > 0) { + SatelliteCountInfo( + satCount = satCount + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val telemetryString = thatNode.getTelemetryString(tempInFahrenheit) + if (telemetryString.isNotEmpty()) { + Text( + text = telemetryString, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + + if (detailsShown || expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + thatNode.validPosition?.let { + DisableSelection { + LinkedCoordinates( + latitude = thatNode.latitude, + longitude = thatNode.longitude, + format = gpsFormat, + nodeName = longName + ) + } + } + val system = + ConfigProtos.Config.DisplayConfig.DisplayUnits.forNumber(distanceUnits) + thatNode.validPosition?.let { position -> + val altitude = position.altitude.metersIn(system) + val elevationSuffix = stringResource(id = R.string.elevation_suffix) + ElevationInfo( + altitude = altitude, + system = system, + suffix = elevationSuffix + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + modifier = Modifier.weight(1f), + text = hwInfoString, + fontSize = MaterialTheme.typography.button.fontSize, + style = style, + ) + Text( + modifier = Modifier.weight(1f), + text = roleName, + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.button.fontSize, + style = style, + ) + Text( + modifier = Modifier.weight(1f), + text = nodeId, + textAlign = TextAlign.End, + fontSize = MaterialTheme.typography.button.fontSize, + style = style, + ) + } + } + } + } + } + } +} + +@Composable +@Preview(showBackground = false) +fun NodeInfoSimplePreview() { + AppTheme { + val thisNode = NodeEntityPreviewParameterProvider().values.first() + val thatNode = NodeEntityPreviewParameterProvider().values.last() + NodeItem( + thisNode = thisNode, + thatNode = thatNode, + 1, + 0, + true, + currentTimeMillis = System.currentTimeMillis() + ) + } +} + +@Composable +@Preview( + showBackground = true, + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, +) +fun NodeInfoPreview( + @PreviewParameter(NodeEntityPreviewParameterProvider::class) + thatNode: NodeEntity +) { + AppTheme { + val thisNode = NodeEntityPreviewParameterProvider().values.first() + Column { + Text( + text = "Details Collapsed", + color = MaterialTheme.colors.onBackground + ) + NodeItem( + thisNode = thisNode, + thatNode = thatNode, + gpsFormat = 0, + distanceUnits = 1, + tempInFahrenheit = true, + expanded = false, + currentTimeMillis = System.currentTimeMillis() + ) + Text( + text = "Details Shown", + color = MaterialTheme.colors.onBackground + ) + NodeItem( + thisNode = thisNode, + thatNode = thatNode, + gpsFormat = 0, + distanceUnits = 1, + tempInFahrenheit = true, + expanded = true, + currentTimeMillis = System.currentTimeMillis() + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt new file mode 100644 index 000000000..42b0bf600 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt @@ -0,0 +1,58 @@ +package com.geeksville.mesh.ui + +import android.view.Gravity +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.PopupMenu +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +internal fun View.nodeMenu( + node: NodeEntity, + ignoreIncomingList: List, + isOurNode: Boolean = false, + onMenuItemAction: MenuItem.() -> Unit, +) = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0).apply { + val isIgnored = ignoreIncomingList.contains(node.num) + + inflate(R.menu.menu_nodes) + menu.apply { + setGroupVisible(R.id.group_remote, !isOurNode) + findItem(R.id.ignore).apply { + isEnabled = isIgnored || ignoreIncomingList.size < 3 + isChecked = isIgnored + } + } + setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.remove -> { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.remove) + .setMessage(R.string.remove_node_text) + .setNeutralButton(R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.send) { _, _ -> + item.onMenuItemAction() + } + .show() + } + + R.id.ignore -> { + val message = if (isIgnored) R.string.ignore_remove else R.string.ignore_add + MaterialAlertDialogBuilder(context) + .setTitle(R.string.ignore) + .setMessage(context.getString(message, node.user.longName)) + .setNeutralButton(R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.send) { _, _ -> + item.onMenuItemAction() + } + .show() + item.isChecked = !item.isChecked + } + + else -> item.onMenuItemAction() + } + true + } + show() +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt new file mode 100644 index 000000000..e7ca39afd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt @@ -0,0 +1,310 @@ +package com.geeksville.mesh.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageView +import androidx.annotation.StringRes +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.databinding.QuickChatSettingsFragmentBinding +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.components.dragContainer +import com.geeksville.mesh.ui.components.dragDropItemsIndexed +import com.geeksville.mesh.ui.components.rememberDragDropState +import com.geeksville.mesh.ui.theme.AppTheme +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class QuickChatSettingsFragment : ScreenFragment("Quick Chat Settings"), Logging { + private var _binding: QuickChatSettingsFragmentBinding? = null + + private val binding get() = _binding!! + + private val model: UIViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = QuickChatSettingsFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.quickChatSettingsToolbar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + binding.quickChatSettingsCreateButton.setOnClickListener { + val builder = createEditDialog(requireContext(), R.string.quick_chat_new) + + builder.builder.setPositiveButton(R.string.add) { _, _ -> + + val name = builder.nameInput.text.toString().trim() + val message = builder.messageInput.text.toString() + if (builder.isNotEmpty()) { + model.addQuickChatAction( + name, message, + if (builder.modeSwitch.isChecked) QuickChatAction.Mode.Instant else QuickChatAction.Mode.Append + ) + } + } + + val dialog = builder.builder.create() + dialog.show() + } + + binding.quickChatSettingsView.setContent { + val actions by model.quickChatActions.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex -> + val list = actions.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + model.updateActionPositions(list) + } + + AppTheme { + LazyColumn( + modifier = Modifier.dragContainer( + dragDropState = dragDropState, + haptics = LocalHapticFeedback.current, + ), + state = listState, + // contentPadding = PaddingValues(16.dp), + ) { + dragDropItemsIndexed( + items = actions, + dragDropState = dragDropState, + key = { _, item -> item.uuid }, + ) { _, action, isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 4.dp) + QuickChatItem( + elevation = elevation, + action = action, + onEditClick = ::onEditAction, + ) + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + data class DialogBuilder( + val builder: MaterialAlertDialogBuilder, + val nameInput: EditText, + val messageInput: EditText, + val modeSwitch: SwitchMaterial, + val instantImage: ImageView + ) { + fun isNotEmpty(): Boolean = nameInput.text.isNotEmpty() and messageInput.text.isNotEmpty() + } + + private fun getMessageName(message: String): String { + return if (message.length <= 3) { + message.uppercase() + } else { + buildString { + append(message.first().uppercase()) + append(message[message.length / 2].uppercase()) + append(message.last().uppercase()) + } + } + } + + private fun createEditDialog(context: Context, @StringRes title: Int): DialogBuilder { + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(title) + + val layout = LayoutInflater.from(context).inflate(R.layout.dialog_add_quick_chat, null) + + val nameInput: EditText = layout.findViewById(R.id.addQuickChatName) + val messageInput: EditText = layout.findViewById(R.id.addQuickChatMessage) + val modeSwitch: SwitchMaterial = layout.findViewById(R.id.addQuickChatMode) + val instantImage: ImageView = layout.findViewById(R.id.addQuickChatInsant) + instantImage.visibility = if (modeSwitch.isChecked) View.VISIBLE else View.INVISIBLE + + // don't change action name on edits + var nameHasChanged = title == R.string.quick_chat_edit + + modeSwitch.setOnCheckedChangeListener { _, _ -> + if (modeSwitch.isChecked) { + modeSwitch.setText(R.string.quick_chat_instant) + instantImage.visibility = View.VISIBLE + } else { + modeSwitch.setText(R.string.quick_chat_append) + instantImage.visibility = View.INVISIBLE + } + } + + messageInput.addTextChangedListener { text -> + if (!nameHasChanged) { + nameInput.setText(getMessageName(text.toString())) + } + } + + nameInput.addTextChangedListener { + if (nameInput.isFocused) nameHasChanged = true + } + + builder.setView(layout) + + return DialogBuilder(builder, nameInput, messageInput, modeSwitch, instantImage) + } + + private fun onEditAction(action: QuickChatAction) { + val builder = createEditDialog(requireContext(), R.string.quick_chat_edit) + builder.nameInput.setText(action.name) + builder.messageInput.setText(action.message) + val isInstant = action.mode == QuickChatAction.Mode.Instant + builder.modeSwitch.isChecked = isInstant + builder.instantImage.visibility = if (isInstant) View.VISIBLE else View.INVISIBLE + + builder.builder.setNegativeButton(R.string.delete) { _, _ -> + model.deleteQuickChatAction(action) + } + builder.builder.setPositiveButton(R.string.save) { _, _ -> + if (builder.isNotEmpty()) { + model.updateQuickChatAction( + action, + builder.nameInput.text.toString(), + builder.messageInput.text.toString(), + if (builder.modeSwitch.isChecked) { + QuickChatAction.Mode.Instant + } else { + QuickChatAction.Mode.Append + } + ) + } + } + val dialog = builder.builder.create() + dialog.show() + } +} + +@Composable +internal fun QuickChatItem( + action: QuickChatAction, + modifier: Modifier = Modifier, + onEditClick: (QuickChatAction) -> Unit = {}, + elevation: Dp = 4.dp, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + elevation = elevation, + shape = RoundedCornerShape(12.dp), + ) { + Surface { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val showInstantIcon = action.mode == QuickChatAction.Mode.Instant + Icon( + painter = painterResource(id = R.drawable.ic_baseline_fast_forward_24), + contentDescription = null, + modifier = Modifier.padding(start = 8.dp), + tint = if (showInstantIcon) LocalContentColor.current else Color.Transparent, + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = action.name, + fontSize = 20.sp, + modifier = Modifier.padding(top = 8.dp) + ) + + Text( + text = action.message, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + IconButton( + onClick = { onEditClick(action) }, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_edit_24), + contentDescription = null + ) + } + + Icon( + painter = painterResource(id = R.drawable.ic_baseline_drag_handle_24), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun QuickChatItemPreview() { + AppTheme { + QuickChatItem( + action = QuickChatAction( + uuid = 0L, + name = "TST", + message = "Test", + mode = QuickChatAction.Mode.Instant, + position = 0, + ), + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/RadioConfigScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/RadioConfigScreen.kt new file mode 100644 index 000000000..442bbd01d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/RadioConfigScreen.kt @@ -0,0 +1,318 @@ +package com.geeksville.mesh.ui + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowRight +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.twotone.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog +import com.geeksville.mesh.ui.components.config.PacketResponseStateDialog + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun RadioConfigScreen( + node: NodeEntity?, + viewModel: RadioConfigViewModel = hiltViewModel(), + modifier: Modifier = Modifier, + onNavigate: (String) -> Unit = {} +) { + val isLocal = node?.num == viewModel.myNodeNum + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + var isWaiting by remember { mutableStateOf(false) } + + if (isWaiting) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = { + isWaiting = false + viewModel.clearPacketResponse() + }, + onComplete = { + val route = state.route + if (ConfigRoute.entries.any { it.name == route } || + ModuleRoute.entries.any { it.name == route }) { + isWaiting = false + viewModel.clearPacketResponse() + onNavigate(route) + } + }, + ) + } + + var deviceProfile by remember { mutableStateOf(null) } + var showEditDeviceProfileDialog by remember { mutableStateOf(false) } + + val importConfigLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + showEditDeviceProfileDialog = true + it.data?.data?.let { uri -> + viewModel.importProfile(uri) { profile -> deviceProfile = profile } + } + } + } + + val exportConfigLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> viewModel.exportProfile(uri, deviceProfile!!) } + } + } + + if (showEditDeviceProfileDialog) { + EditDeviceProfileDialog( + title = if (deviceProfile != null) "Import configuration" else "Export configuration", + deviceProfile = deviceProfile ?: viewModel.currentDeviceProfile, + onConfirm = { + showEditDeviceProfileDialog = false + if (deviceProfile != null) { + viewModel.installProfile(it) + } else { + deviceProfile = it + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + putExtra(Intent.EXTRA_TITLE, "${node!!.num.toUInt()}.cfg") + } + exportConfigLauncher.launch(intent) + } + }, + onDismiss = { + showEditDeviceProfileDialog = false + deviceProfile = null + } + ) + } + + RadioConfigItemList( + enabled = state.connected && !isWaiting, + isLocal = isLocal, + modifier = modifier, + onRouteClick = { route -> + isWaiting = true + viewModel.setResponseStateLoading(route) + }, + onImport = { + viewModel.clearPacketResponse() + deviceProfile = null + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + } + importConfigLauncher.launch(intent) + }, + onExport = { + viewModel.clearPacketResponse() + deviceProfile = null + showEditDeviceProfileDialog = true + }, + ) +} + +@Composable +fun NavCard( + title: String, + enabled: Boolean, + icon: ImageVector? = null, + onClick: () -> Unit +) { + val color = if (enabled) { + MaterialTheme.colors.onSurface + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable(enabled = enabled) { onClick() }, + elevation = 4.dp + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = color, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.body1, + color = color, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon", + modifier = Modifier.wrapContentSize(), + tint = color, + ) + } + } +} + +@Composable +private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) { + var showDialog by remember { mutableStateOf(false) } + if (showDialog) AlertDialog( + onDismissRequest = {}, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.TwoTone.Warning, + contentDescription = "warning", + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = "${stringResource(title)}?\n") + Icon( + imageVector = Icons.TwoTone.Warning, + contentDescription = "warning", + modifier = Modifier.padding(start = 8.dp) + ) + } + }, + buttons = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = Modifier.weight(1f), + onClick = { showDialog = false }, + ) { Text(stringResource(R.string.cancel)) } + Button( + modifier = Modifier.weight(1f), + onClick = { + showDialog = false + onClick() + }, + ) { Text(stringResource(R.string.send)) } + } + } + ) + + Column { + Spacer(modifier = Modifier.height(4.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = enabled, + onClick = { showDialog = true }, + ) { Text(text = stringResource(title)) } + } +} + +@Composable +private fun RadioConfigItemList( + enabled: Boolean = true, + isLocal: Boolean = true, + modifier: Modifier = Modifier, + onRouteClick: (Enum<*>) -> Unit = {}, + onImport: () -> Unit = {}, + onExport: () -> Unit = {}, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { PreferenceCategory(stringResource(R.string.device_settings)) } + items(ConfigRoute.entries) { + NavCard(title = it.title, icon = it.icon, enabled = enabled) { onRouteClick(it) } + } + + item { PreferenceCategory(stringResource(R.string.module_settings)) } + items(ModuleRoute.entries) { + NavCard(title = it.title, icon = it.icon, enabled = enabled) { onRouteClick(it) } + } + + if (isLocal) { + item { + PreferenceCategory("Backup & Restore") + NavCard( + title = "Import configuration", + icon = Icons.Default.Download, + enabled = enabled, + onClick = onImport, + ) + NavCard( + title = "Export configuration", + icon = Icons.Default.Upload, + enabled = enabled, + onClick = onExport, + ) + } + } + + items(AdminRoute.entries) { NavButton(it.title, enabled) { onRouteClick(it) } } + } +} + +@Preview(showBackground = true) +@Composable +private fun RadioSettingsScreenPreview() { + RadioConfigItemList() +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt new file mode 100644 index 000000000..21798b848 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ScreenFragment.kt @@ -0,0 +1,22 @@ +package com.geeksville.mesh.ui + +import androidx.fragment.app.Fragment +import com.geeksville.mesh.android.GeeksvilleApplication + +/** + * A fragment that represents a current 'screen' in our app. + * + * Useful for tracking analytics + */ +open class ScreenFragment(private val screenName: String) : Fragment() { + + override fun onResume() { + super.onResume() + GeeksvilleApplication.analytics.sendScreenView(screenName) + } + + override fun onPause() { + GeeksvilleApplication.analytics.endScreenView() + super.onPause() + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt new file mode 100644 index 000000000..1cdba6206 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -0,0 +1,506 @@ +package com.geeksville.mesh.ui + +import android.net.InetAddresses +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.util.Patterns +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.RadioButton +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.asLiveData +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.android.* +import com.geeksville.mesh.databinding.SettingsFragmentBinding +import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.BluetoothViewModel +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.repository.location.LocationRepository +import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.util.exceptionToSnackbar +import com.geeksville.mesh.util.onEditorAction +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class SettingsFragment : ScreenFragment("Settings"), Logging { + private var _binding: SettingsFragmentBinding? = null + + // This property is only valid between onCreateView and onDestroyView. + private val binding get() = _binding!! + + private val scanModel: BTScanModel by activityViewModels() + private val bluetoothViewModel: BluetoothViewModel by activityViewModels() + private val model: UIViewModel by activityViewModels() + + @Inject + internal lateinit var locationRepository: LocationRepository + + private val hasGps by lazy { requireContext().hasGps() } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = SettingsFragmentBinding.inflate(inflater, container, false) + return binding.root + } + + /** + * Pull the latest device info from the model and into the GUI + */ + private fun updateNodeInfo() { + val connectionState = model.connectionState.value + val isConnected = connectionState == MeshService.ConnectionState.CONNECTED + + binding.nodeSettings.visibility = if (isConnected) View.VISIBLE else View.GONE + binding.provideLocationCheckbox.visibility = if (isConnected) View.VISIBLE else View.GONE + + binding.usernameEditText.isEnabled = isConnected && !model.isManaged + + if (hasGps) { + binding.provideLocationCheckbox.isEnabled = true + } else { + binding.provideLocationCheckbox.isChecked = false + binding.provideLocationCheckbox.isEnabled = false + } + + // update the region selection from the device + val region = model.region + val spinner = binding.regionSpinner + spinner.onItemSelectedListener = null + + debug("current region is $region") + var regionIndex = regions.indexOfFirst { it.regionCode == region } + if (regionIndex == -1) { // Not found, probably because the device has a region our app doesn't yet understand. Punt and say Unset + regionIndex = ConfigProtos.Config.LoRaConfig.RegionCode.UNSET_VALUE + } + + // We don't want to be notified of our own changes, so turn off listener while making them + spinner.setSelection(regionIndex, false) + spinner.onItemSelectedListener = regionSpinnerListener + spinner.isEnabled = !model.isManaged + + // Update the status string (highest priority messages first) + val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val info = model.myNodeInfo.value + when (connectionState) { + MeshService.ConnectionState.CONNECTED -> + if (regionUnset) R.string.must_set_region else R.string.connected_to + MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected + MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping + else -> null + }?.let { + val firmwareString = info?.firmwareString ?: getString(R.string.unknown) + scanModel.setErrorText(getString(it, firmwareString)) + } + } + + private val regionSpinnerListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>, + view: View, + position: Int, + id: Long + ) { + val item = RegionInfo.entries[position] + val asProto = item.regionCode + exceptionToSnackbar(requireView()) { + debug("regionSpinner onItemSelected $asProto") + if (asProto != model.region) model.region = asProto + } + updateNodeInfo() // We might have just changed Unset to set + } + + override fun onNothingSelected(parent: AdapterView<*>) { + // TODO("Not yet implemented") + } + } + + private val regions = RegionInfo.entries + + private fun initCommonUI() { + + val requestLocationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.entries.all { it.value }) { + model.provideLocation.value = true + model.meshService?.startProvideLocation() + } else { + debug("User denied location permission") + model.showSnackbar(getString(R.string.why_background_required)) + } + bluetoothViewModel.permissionsUpdated() + } + + // init our region spinner + val spinner = binding.regionSpinner + val regionAdapter = object : ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + regions + ) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + (view as? TextView)?.text = regions[position].name + return view + } + + override fun getDropDownView( + position: Int, + convertView: View?, + parent: ViewGroup + ): View { + val view = super.getDropDownView(position, convertView, parent) + (view as? TextView)?.text = regions[position].description + return view + } + } + regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinner.adapter = regionAdapter + + model.ourNodeInfo.asLiveData().observe(viewLifecycleOwner) { node -> + binding.usernameEditText.setText(node?.user?.longName.orEmpty()) + } + + scanModel.devices.observe(viewLifecycleOwner) { devices -> + updateDevicesButtons(devices) + } + + // Only let user edit their name or set software update while connected to a radio + model.connectionState.observe(viewLifecycleOwner) { + updateNodeInfo() + } + + model.localConfig.asLiveData().observe(viewLifecycleOwner) { + if (model.isConnected()) updateNodeInfo() + } + + // Also watch myNodeInfo because it might change later + model.myNodeInfo.asLiveData().observe(viewLifecycleOwner) { + updateNodeInfo() + } + + scanModel.errorText.observe(viewLifecycleOwner) { errMsg -> + if (errMsg != null) { + binding.scanStatusText.text = errMsg + } + } + + var scanDialog: AlertDialog? = null + scanModel.scanResult.observe(viewLifecycleOwner) { results -> + val devices = results.values.ifEmpty { return@observe } + scanDialog?.dismiss() + scanDialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle("Select a Bluetooth device") + .setSingleChoiceItems( + devices.map { it.name }.toTypedArray(), + -1 + ) { dialog, position -> + val selectedDevice = devices.elementAt(position) + scanModel.onSelected(selectedDevice) + scanModel.clearScanResults() + dialog.dismiss() + scanDialog = null + } + .setPositiveButton(R.string.cancel) { dialog, _ -> + scanModel.clearScanResults() + dialog.dismiss() + scanDialog = null + } + .show() + } + + // show the spinner when [spinner] is true + scanModel.spinner.observe(viewLifecycleOwner) { show -> + binding.changeRadioButton.isEnabled = !show + binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE + } + + binding.usernameEditText.onEditorAction(EditorInfo.IME_ACTION_DONE) { + debug("received IME_ACTION_DONE") + val n = binding.usernameEditText.text.toString().trim() + if (n.isNotEmpty()) model.setOwner(n) + requireActivity().hideKeyboard() + } + + // Observe receivingLocationUpdates state and update provideLocationCheckbox + locationRepository.receivingLocationUpdates.asLiveData().observe(viewLifecycleOwner) { + binding.provideLocationCheckbox.isChecked = it + } + + binding.provideLocationCheckbox.setOnCheckedChangeListener { view, isChecked -> + // Don't check the box until the system setting changes + view.isChecked = isChecked && requireContext().hasLocationPermission() + + if (view.isPressed) { // We want to ignore changes caused by code (as opposed to the user) + debug("User changed location tracking to $isChecked") + model.provideLocation.value = isChecked + if (isChecked && !view.isChecked) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.background_required) + .setMessage(R.string.why_background_required) + .setNeutralButton(R.string.cancel) { _, _ -> + debug("User denied background permission") + } + .setPositiveButton(getString(R.string.accept)) { _, _ -> + // Make sure we have location permission (prerequisite) + if (!requireContext().hasLocationPermission()) { + requestLocationPermissionLauncher.launch(requireContext().getLocationPermissions()) + } + } + .show() + } + } + if (view.isChecked) { + checkLocationEnabled(getString(R.string.location_disabled)) + model.meshService?.startProvideLocation() + } else { + model.meshService?.stopProvideLocation() + } + } + + val app = (requireContext().applicationContext as GeeksvilleApplication) + val isGooglePlayAvailable = requireContext().isGooglePlayAvailable() + val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable + + // Set analytics checkbox + binding.analyticsOkayCheckbox.isEnabled = isGooglePlayAvailable + binding.analyticsOkayCheckbox.isChecked = isAnalyticsAllowed + + binding.analyticsOkayCheckbox.setOnCheckedChangeListener { _, isChecked -> + debug("User changed analytics to $isChecked") + app.isAnalyticsAllowed = isChecked + binding.reportBugButton.isEnabled = isAnalyticsAllowed + } + + // report bug button only enabled if analytics is allowed + binding.reportBugButton.isEnabled = isAnalyticsAllowed + binding.reportBugButton.setOnClickListener(::showReportBugDialog) + } + + @Suppress("UNUSED_PARAMETER") + private fun showReportBugDialog(view: View) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.report_a_bug) + .setMessage(getString(R.string.report_bug_text)) + .setNeutralButton(R.string.cancel) { _, _ -> + debug("Decided not to report a bug") + } + .setPositiveButton(getString(R.string.report)) { _, _ -> + reportError("Clicked Report A Bug") + model.showSnackbar("Bug report sent!") + } + .show() + } + + private var tapCount = 0 + private var lastTapTime: Long = 0 + + private fun addDeviceButton(device: BTScanModel.DeviceListEntry, enabled: Boolean) { + val b = RadioButton(requireActivity()) + b.text = device.name + b.id = View.generateViewId() + b.isEnabled = enabled + b.isChecked = device.fullAddress == scanModel.selectedNotNull + binding.deviceRadioGroup.addView(b) + + b.setOnClickListener { + if (device.fullAddress == "n") { + val currentTapTime = System.currentTimeMillis() + if (currentTapTime - lastTapTime > TAP_THRESHOLD) { + tapCount = 0 + } + lastTapTime = currentTapTime + tapCount++ + + if (tapCount >= TAP_TRIGGER) { + model.showSnackbar("Demo Mode enabled") + scanModel.showMockInterface() + } + } + if (!device.bonded) { // If user just clicked on us, try to bond + binding.scanStatusText.setText(R.string.starting_pairing) + } + b.isChecked = scanModel.onSelected(device) + } + } + + private fun addManualDeviceButton() { + val deviceSelectIPAddress = binding.radioButtonManual + val inputIPAddress = binding.editManualAddress + + deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress() + deviceSelectIPAddress.setOnClickListener { + deviceSelectIPAddress.isChecked = scanModel.onSelected(BTScanModel.DeviceListEntry("", "t" + inputIPAddress.text, true)) + } + + binding.deviceRadioGroup.addView(deviceSelectIPAddress) + binding.deviceRadioGroup.addView(inputIPAddress) + + inputIPAddress.doAfterTextChanged { + deviceSelectIPAddress.isEnabled = inputIPAddress.text.isIPAddress() + } + } + + private fun updateDevicesButtons(devices: MutableMap?) { + // Remove the old radio buttons and repopulate + binding.deviceRadioGroup.removeAllViews() + + if (devices == null) return + + var hasShownOurDevice = false + devices.values + // Display the device list in alphabetical order while keeping the "None (Disabled)" + // device (fullAddress == n) at the top + .sortedBy { dle -> if (dle.fullAddress == "n") "0" else dle.name } + .forEach { device -> + if (device.fullAddress == scanModel.selectedNotNull) { + hasShownOurDevice = true + } + addDeviceButton(device, true) + } + + // The selected device is not in the scan; it is either offline, or it doesn't advertise + // itself (most BLE devices don't advertise when connected). + // Show it in the list, greyed out based on connection status. + if (!hasShownOurDevice) { + // Note: we pull this into a tempvar, because otherwise some other thread can change selectedAddress after our null check + // and before use + val curAddr = scanModel.selectedAddress + if (curAddr != null) { + val curDevice = BTScanModel.DeviceListEntry(curAddr.substring(1), curAddr, false) + addDeviceButton(curDevice, model.isConnected()) + } + } + + addManualDeviceButton() + + // get rid of the warning text once at least one device is paired. + // If we are running on an emulator, always leave this message showing so we can test the worst case layout + val curRadio = scanModel.selectedAddress + + if (curRadio != null && curRadio != "m") { + binding.warningNotPaired.visibility = View.GONE + } else if (bluetoothViewModel.enabled.value == true) { + binding.warningNotPaired.visibility = View.VISIBLE + scanModel.setErrorText(getString(R.string.not_paired_yet)) + } + } + + // per https://developer.android.com/guide/topics/connectivity/bluetooth/find-ble-devices + private var scanning = false + private fun scanLeDevice() { + if (!checkBTEnabled()) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) checkLocationEnabled() + + if (!scanning) { // Stops scanning after a pre-defined scan period. + Handler(Looper.getMainLooper()).postDelayed({ + scanning = false + scanModel.stopScan() + }, SCAN_PERIOD) + scanning = true + scanModel.startScan() + } else { + scanning = false + scanModel.stopScan() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initCommonUI() + + val requestPermissionAndScanLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.entries.all { it.value }) { + info("Bluetooth permissions granted") + scanLeDevice() + } else { + warn("Bluetooth permissions denied") + model.showSnackbar(requireContext().permissionMissing) + } + bluetoothViewModel.permissionsUpdated() + } + + binding.changeRadioButton.setOnClickListener { + debug("User clicked changeRadioButton") + val bluetoothPermissions = requireContext().getBluetoothPermissions() + if (bluetoothPermissions.isEmpty()) { + scanLeDevice() + } else { + requireContext().rationaleDialog( + shouldShowRequestPermissionRationale(bluetoothPermissions) + ) { + requestPermissionAndScanLauncher.launch(bluetoothPermissions) + } + } + } + } + + // If the user has not turned on location access throw up a warning + private fun checkLocationEnabled( + // Default warning valid only for classic bluetooth scan + warningReason: String = getString(R.string.location_disabled_warning) + ) { + if (requireContext().gpsDisabled()) { + warn("Telling user we need location access") + model.showSnackbar(warningReason) + } + } + + private fun checkBTEnabled(): Boolean = (bluetoothViewModel.enabled.value == true).also { enabled -> + if (!enabled) { + warn("Telling user bluetooth is disabled") + model.showSnackbar(R.string.bluetooth_disabled) + } + } + + override fun onResume() { + super.onResume() + + // Warn user if BLE device is selected but BLE disabled + if (scanModel.selectedBluetooth) checkBTEnabled() + + // Warn user if provide location is selected but location disabled + if (binding.provideLocationCheckbox.isChecked) { + checkLocationEnabled(getString(R.string.location_disabled)) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds + private const val TAP_TRIGGER: Int = 7 + private const val TAP_THRESHOLD: Long = 500 // max 500 ms between taps + } + + private fun Editable.isIPAddress(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + InetAddresses.isNumericAddress(this.toString()) + } else { + @Suppress("DEPRECATION") + Patterns.IP_ADDRESS.matcher(this).matches() + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SignalInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/SignalInfo.kt new file mode 100644 index 000000000..162f755c9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SignalInfo.kt @@ -0,0 +1,108 @@ +package com.geeksville.mesh.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.geeksville.mesh.R +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.theme.AppTheme + +const val MAX_VALID_SNR = 100F +const val MAX_VALID_RSSI = 0 + +@Composable +fun signalInfo( + modifier: Modifier = Modifier, + node: NodeEntity, + isThisNode: Boolean +): Boolean { + val text = if (isThisNode) { + stringResource(R.string.channel_air_util).format( + node.deviceMetrics.channelUtilization, + node.deviceMetrics.airUtilTx + ) + } else { + buildList { + val hopsString = "%s: %s".format( + stringResource(R.string.hops_away), + if (node.hopsAway == -1) { + "?" + } else { + node.hopsAway.toString() + } + ) + if (node.channel > 0) { + add("ch:${node.channel}") + } + if (node.hopsAway <= 0) { + if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) { + add("RSSI: %d SNR: %.1f".format(node.rssi, node.snr)) + } + } + if (node.hopsAway != 0) add(hopsString) + }.joinToString(" | ") + } + return if (text.isNotEmpty()) { + Text( + modifier = modifier, + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + true + } else { + false + } +} + +@Composable +@Preview(showBackground = true) +fun SignalInfoSimplePreview() { + AppTheme { + signalInfo( + node = NodeEntity( + num = 1, + lastHeard = 0, + channel = 0, + snr = 12.5F, + rssi = -42, + hopsAway = 0 + ), + isThisNode = false + ) + } +} + +@PreviewLightDark +@Composable +fun SignalInfoPreview( + @PreviewParameter(NodeEntityPreviewParameterProvider::class) + node: NodeEntity +) { + AppTheme { + signalInfo( + node = node, + isThisNode = false + ) + } +} + +@Composable +@PreviewLightDark +fun SignalInfoSelfPreview( + @PreviewParameter(NodeEntityPreviewParameterProvider::class) + node: NodeEntity +) { + AppTheme { + signalInfo( + node = node, + isThisNode = true + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt new file mode 100644 index 000000000..5e6935a19 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -0,0 +1,183 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.components.NodeFilterTextField +import com.geeksville.mesh.ui.components.rememberTimeTickWithLifecycle +import com.geeksville.mesh.ui.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class UsersFragment : ScreenFragment("Users"), Logging { + + private val model: UIViewModel by activityViewModels() + + private fun popup(node: NodeEntity) { + if (!model.isConnected()) return + val isOurNode = node.num == model.myNodeNum + val ignoreIncomingList = model.ignoreIncomingList + + requireView().nodeMenu( + node = node, + ignoreIncomingList = ignoreIncomingList, + isOurNode = isOurNode, + ) { + when (itemId) { + R.id.direct_message -> { + navigateToMessages(node) + } + + R.id.request_position -> { + model.requestPosition(node.num) + } + + R.id.traceroute -> { + model.requestTraceroute(node.num) + } + + R.id.remove -> { + model.removeNode(node.num) + } + + R.id.ignore -> { + model.ignoreIncomingList = ignoreIncomingList.toMutableList().apply { + if (contains(node.num)) { + debug("removed '${node.num}' from ignore list") + remove(node.num) + } else { + debug("added '${node.num}' to ignore list") + add(node.num) + } + } + } + + R.id.more_details -> { + navigateToRadioConfig(node.num) + } + + R.id.request_userinfo -> { + model.requestUserInfo(node.num) + } + } + } + } + + private fun navigateToMessages(node: NodeEntity) = node.user.let { user -> + val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC + val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + val contactKey = "$channel${user.id}" + info("calling MessagesFragment filter: $contactKey") + parentFragmentManager.navigateToMessages(contactKey, user.longName) + } + + private fun navigateToRadioConfig(nodeNum: Int) { + info("calling NodeDetails --> destNum: $nodeNum") + parentFragmentManager.navigateToNavGraph(nodeNum, "NodeDetails") + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + NodesScreen(model = model, chipClicked = ::popup) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NodesScreen( + model: UIViewModel = hiltViewModel(), + chipClicked: (NodeEntity) -> Unit, +) { + val focusManager = LocalFocusManager.current + val state by model.nodesUiState.collectAsStateWithLifecycle() + + val nodes by model.nodeList.collectAsStateWithLifecycle() + val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val focusedNode by model.focusedNode.collectAsStateWithLifecycle() + LaunchedEffect(focusedNode) { + focusedNode?.let { node -> + val index = nodes.indexOfFirst { it.num == node.num } + if (index != -1) { + listState.animateScrollToItem(index) + } + model.focusUserNode(null) + } + } + + val currentTimeMillis = rememberTimeTickWithLifecycle() + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + stickyHeader { + NodeFilterTextField( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + filterText = state.filter, + onTextChange = model::setNodeFilterText, + currentSortOption = state.sort, + onSortSelect = model::setSortOption, + includeUnknown = state.includeUnknown, + onToggleIncludeUnknown = model::toggleIncludeUnknown, + showDetails = state.showDetails, + onToggleShowDetails = model::toggleShowDetails, + ) + } + + items(nodes, key = { it.num }) { node -> + NodeItem( + thisNode = ourNode, + thatNode = node, + gpsFormat = state.gpsFormat, + distanceUnits = state.distanceUnits, + tempInFahrenheit = state.tempInFahrenheit, + isIgnored = state.ignoreIncomingList.contains(node.num), + chipClicked = { + focusManager.clearFocus() + chipClicked(node) + }, + blinking = node == focusedNode, + expanded = state.showDetails, + currentTimeMillis = currentTimeMillis, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/AdaptiveTwoPane.kt b/app/src/main/java/com/geeksville/mesh/ui/components/AdaptiveTwoPane.kt new file mode 100644 index 000000000..713de3b83 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/AdaptiveTwoPane.kt @@ -0,0 +1,32 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AdaptiveTwoPane( + first: @Composable ColumnScope.() -> Unit, + second: @Composable ColumnScope.() -> Unit, +) = BoxWithConstraints { + val compactWidth = maxWidth < 600.dp + Row { + Column(modifier = Modifier.weight(1f)) { + first() + + if (compactWidth) { + second() + } + } + + if (!compactWidth) { + Column(modifier = Modifier.weight(1f)) { + second() + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/AutoLinkText.kt b/app/src/main/java/com/geeksville/mesh/ui/components/AutoLinkText.kt new file mode 100644 index 000000000..f8edf20d5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/AutoLinkText.kt @@ -0,0 +1,76 @@ +package com.geeksville.mesh.ui.components + +import android.text.Spannable +import android.text.Spannable.Factory +import android.text.style.URLSpan +import android.text.util.Linkify +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.text.util.LinkifyCompat +import com.geeksville.mesh.ui.theme.HyperlinkBlue + +private val DefaultTextLinkStyles = TextLinkStyles( + style = SpanStyle( + color = HyperlinkBlue, + textDecoration = TextDecoration.Underline, + ) +) + +@Composable +fun AutoLinkText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + linkStyles: TextLinkStyles = DefaultTextLinkStyles, +) { + val spannable = remember(text) { + linkify(text) + } + Text( + text = spannable.toAnnotatedString(linkStyles), + modifier = modifier, + style = style, + ) +} + +private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also { + LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS) +} + +private fun Spannable.toAnnotatedString( + linkStyles: TextLinkStyles, +): AnnotatedString = buildAnnotatedString { + val spannable = this@toAnnotatedString + var lastEnd = 0 + spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + append(spannable.subSequence(lastEnd, start)) + when (span) { + is URLSpan -> withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) { + append(spannable.subSequence(start, end)) + } + + else -> append(spannable.subSequence(start, end)) + } + lastEnd = end + } + append(spannable.subSequence(lastEnd, spannable.length)) +} + +@Preview(showBackground = true) +@Composable +private fun AutoLinkTextPreview() { + AutoLinkText("A text containing a link https://example.com") +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/BitwisePreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/BitwisePreference.kt new file mode 100644 index 000000000..fc32204f5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/BitwisePreference.kt @@ -0,0 +1,93 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.KeyboardArrowDown +import androidx.compose.material.icons.twotone.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.geeksville.mesh.R + +@Composable +fun BitwisePreference( + title: String, + value: Int, + enabled: Boolean, + items: List>, + onItemSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var dropDownExpanded by remember { mutableStateOf(value = false) } + + RegularPreference( + title = title, + subtitle = value.toString(), + onClick = { dropDownExpanded = !dropDownExpanded }, + enabled = enabled, + trailingIcon = if (dropDownExpanded) { + Icons.TwoTone.KeyboardArrowUp + } else { + Icons.TwoTone.KeyboardArrowDown + }, + ) + + Box { + DropdownMenu( + expanded = dropDownExpanded, + onDismissRequest = { dropDownExpanded = !dropDownExpanded }, + ) { + items.forEach { item -> + DropdownMenuItem( + onClick = { onItemSelected(value xor item.first) }, + modifier = modifier.fillMaxWidth(), + content = { + Text( + text = item.second, + overflow = TextOverflow.Ellipsis, + ) + Checkbox( + modifier = modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + checked = value and item.first != 0, + onCheckedChange = { onItemSelected(value xor item.first) }, + enabled = enabled, + ) + } + ) + } + PreferenceFooter( + enabled = enabled, + negativeText = R.string.clear, + onNegativeClicked = { onItemSelected(0) }, + positiveText = R.string.close, + onPositiveClicked = { dropDownExpanded = false }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwisePreferencePreview() { + BitwisePreference( + title = "Settings", + value = 3, + enabled = true, + items = listOf(1 to "TEST1", 2 to "TEST2"), + onItemSelected = {} + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/ClickableTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/components/ClickableTextField.kt new file mode 100644 index 000000000..080723e17 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/ClickableTextField.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.material.Icon +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource + +@Composable +fun ClickableTextField( + @StringRes label: Int, + enabled: Boolean, + trailingIcon: ImageVector, + value: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isError: Boolean = false, +) { + val source = remember { MutableInteractionSource() } + val isPressed by source.collectIsPressedAsState() + if (isPressed) onClick() + + OutlinedTextField( + value, + onValueChange = {}, + enabled = enabled, + readOnly = true, + label = { Text(stringResource(label)) }, + trailingIcon = { Icon(trailingIcon, null) }, + isError = isError, + interactionSource = source, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt new file mode 100644 index 000000000..45d5e60cb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -0,0 +1,278 @@ +package com.geeksville.mesh.ui.components + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT +import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT +import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING +import java.text.DateFormat + +object CommonCharts { + val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + const val X_AXIS_SPACING = 8f + const val LEFT_LABEL_SPACING = 36 + const val MS_PER_SEC = 1000.0f + const val LINE_LIMIT = 4 + const val TEXT_PAINT_ALPHA = 192 +} + +private const val LINE_ON = 10f +private const val LINE_OFF = 20f + +data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false) + +@Composable +fun ChartHeader(amount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$amount ${stringResource(R.string.logs)}", + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +/** + * Draws chart lines and labels with respect to the Y-axis range; defined by (`maxValue` - `minValue`). + * + * @param labelColor The color to be used for the Y labels. + * @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart. + * @param leaveSpace When true the lines will leave space for Y labels on the left side of the graph. + */ +@Composable +fun ChartOverlay( + modifier: Modifier, + labelColor: Color, + lineColors: List, + minValue: Float, + maxValue: Float, + leaveSpace: Boolean = false +) { + val range = maxValue - minValue + val verticalSpacing = range / LINE_LIMIT + val density = LocalDensity.current + Canvas(modifier = modifier) { + + val lineStart = if (leaveSpace) LEFT_LABEL_SPACING.dp.toPx() else 0f + val height = size.height + val width = size.width - 28.dp.toPx() + + /* Horizontal Lines */ + var lineY = minValue + for (i in 0..LINE_LIMIT) { + val ratio = (lineY - minValue) / range + val y = height - (ratio * height) + drawLine( + start = Offset(lineStart, y), + end = Offset(width, y), + color = lineColors[i], + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f) + ) + lineY += verticalSpacing + } + + /* Y Labels */ + + val textPaint = Paint().apply { + color = labelColor.toArgb() + textAlign = Paint.Align.LEFT + textSize = density.run { 12.dp.toPx() } + typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) + alpha = TEXT_PAINT_ALPHA + } + drawContext.canvas.nativeCanvas.apply { + var label = minValue + for (i in 0..LINE_LIMIT) { + val ratio = (label - minValue) / range + val y = height - (ratio * height) + drawText( + "${label.toInt()}", + width + 4.dp.toPx(), + y + 4.dp.toPx(), + textPaint + ) + label += verticalSpacing + } + } + } +} + +/** + * Draws the `oldest` and `newest` times for the respective telemetry data. + * Expects time in milliseconds + */ +@Composable +fun TimeLabels( + oldest: Float, + newest: Float +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = TIME_FORMAT.format(oldest), + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = TIME_FORMAT.format(newest), + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = 12.sp + ) + } +} + +/** + * Creates the legend that identifies the colors used for the graph. + * + * @param legendData A list containing the `LegendData` to build the labels. + * @param promptInfoDialog Executes when the user presses the info icon. + */ +@Composable +fun Legend(legendData: List, promptInfoDialog: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(1f)) + for (data in legendData) { + LegendLabel( + text = stringResource(data.nameRes), + color = data.color, + isLine = data.isLine + ) + + Spacer(modifier = Modifier.width(4.dp)) + } + + Icon( + imageVector = Icons.Default.Info, + modifier = Modifier.clickable { promptInfoDialog() }, + contentDescription = stringResource(R.string.info) + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +/** + * Displays a dialog with information about the legend items. + * + * @param pairedRes A list of `Pair`s containing (term, definition). + * @param onDismiss Executes when the user presses the close button. + */ +@Composable +fun LegendInfoDialog(pairedRes: List>, onDismiss: () -> Unit) { + AlertDialog( + title = { + Text( + text = stringResource(R.string.info), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + }, + text = { + Column { + for (pair in pairedRes) { + Text( + text = stringResource(pair.first), + style = TextStyle(fontWeight = FontWeight.Bold), + textDecoration = TextDecoration.Underline + ) + Text( + text = stringResource(pair.second), + style = TextStyle.Default, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background + ) +} + +@Composable +private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { + Canvas( + modifier = Modifier.size(4.dp) + ) { + if (isLine) { + drawLine( + color = color, + start = Offset(x = 0f, y = size.height / 2f), + end = Offset(x = 16f, y = size.height / 2f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round, + ) + } else { + drawCircle( + color = color + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt new file mode 100644 index 000000000..004d06581 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -0,0 +1,253 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.BatteryInfo +import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT +import com.geeksville.mesh.ui.theme.Orange + +private val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) +private const val MAX_PERCENT_VALUE = 100f +private enum class Device { + BATTERY, + CH_UTIL, + AIR_UTIL +} +private val LEGEND_DATA = listOf( + LegendData(nameRes = R.string.battery, color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], isLine = true), + LegendData(nameRes = R.string.channel_utilization, color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal]), + LegendData(nameRes = R.string.air_utilization, color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal]), +) + +@Composable +fun DeviceMetricsScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var displayInfoDialog by remember { mutableStateOf(false) } + + Column { + + if (displayInfoDialog) { + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.channel_utilization, R.string.ch_util_definition), + Pair(R.string.air_utilization, R.string.air_util_definition) + ), + onDismiss = { displayInfoDialog = false } + ) + } + + DeviceMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + state.deviceMetrics.reversed(), + promptInfoDialog = { displayInfoDialog = true } + ) + /* Device Metric Cards */ + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(state.deviceMetrics) { telemetry -> DeviceMetricsCard(telemetry) } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun DeviceMetricsChart( + modifier: Modifier = Modifier, + telemetries: List, + promptInfoDialog: () -> Unit +) { + + ChartHeader(amount = telemetries.size) + if (telemetries.isEmpty()) return + + TimeLabels( + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val spacing = X_AXIS_SPACING + + Box(contentAlignment = Alignment.TopStart) { + + /* + * The order of the colors are with respect to the ChUtil. + * 25 - 49 Orange + * 50 - 100 Red + */ + ChartOverlay( + modifier, + graphColor, + lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor), + minValue = 0f, + maxValue = 100f + ) + + /* Plot Battery Line, ChUtil, and AirUtilTx */ + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + val spacePerEntry = (width - spacing) / telemetries.size + val dataPointRadius = 2.dp.toPx() + var lastX: Float + val strokePath = Path().apply { + for (i in telemetries.indices) { + val telemetry = telemetries[i] + val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last() + val leftRatio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + val rightRatio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + + val x1 = spacing + i * spacePerEntry + val y1 = height - (leftRatio * height) + + /* Channel Utilization */ + val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE + val yChUtil = height - (chUtilRatio * height) + drawCircle( + color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], + radius = dataPointRadius, + center = Offset(x1, yChUtil) + ) + + /* Air Utilization Transmit */ + val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE + val yAirUtil = height - (airUtilRatio * height) + drawCircle( + color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], + radius = dataPointRadius, + center = Offset(x1, yAirUtil) + ) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + + lastX = (x1 + x2) / 2f + + quadraticBezierTo(x1, y1, lastX, (y1 + y2) / 2f) + } + } + + /* Battery Line */ + drawPath( + path = strokePath, + color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], + style = Stroke( + width = dataPointRadius, + cap = StrokeCap.Round + ) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + + Legend(legendData = LEGEND_DATA, promptInfoDialog) + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +private fun DeviceMetricsCard(telemetry: Telemetry) { + val deviceMetrics = telemetry.deviceMetrics + val time = telemetry.time * MS_PER_SEC + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + /* Time, Battery, and Voltage */ + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + + BatteryInfo( + batteryLevel = deviceMetrics.batteryLevel, + voltage = deviceMetrics.voltage + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + /* Channel Utilization and Air Utilization Tx */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val text = stringResource(R.string.channel_air_util).format( + deviceMetrics.channelUtilization, + deviceMetrics.airUtilTx + ) + Text( + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DropDownPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DropDownPreference.kt new file mode 100644 index 000000000..2224b23f9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DropDownPreference.kt @@ -0,0 +1,131 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.KeyboardArrowDown +import androidx.compose.material.icons.twotone.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.geeksville.mesh.R +import com.google.protobuf.ProtocolMessageEnum + +@Composable +fun > DropDownPreference( + title: String, + enabled: Boolean, + selectedItem: T, + onItemSelected: (T) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, +) { + DropDownPreference( + title = title, + enabled = enabled, + items = selectedItem.declaringJavaClass.enumConstants + ?.filter { it.name != "UNRECOGNIZED" }?.map { it to it.name } ?: emptyList(), + selectedItem = selectedItem, + onItemSelected = onItemSelected, + modifier = modifier, + summary = summary, + ) +} + +@Composable +fun DropDownPreference( + title: String, + enabled: Boolean, + items: List>, + selectedItem: T, + onItemSelected: (T) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, +) { + var dropDownExpanded by remember { mutableStateOf(value = false) } + + val deprecatedItems: List = remember { + if (selectedItem is ProtocolMessageEnum) { + val enum = (selectedItem as? Enum<*>)?.declaringJavaClass?.enumConstants + val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType + + @Suppress("UNCHECKED_CAST") + enum?.filter { entries -> + descriptor.values.any { it.name == entries.name && it.options.deprecated } + } as? List ?: emptyList() // Safe cast to List or return emptyList if cast fails + } else { + emptyList() + } + } + + RegularPreference( + title = title, + subtitle = items.find { it.first == selectedItem }?.second + ?: stringResource(id = R.string.unrecognized), + onClick = { + dropDownExpanded = true + }, + enabled = enabled, + trailingIcon = if (dropDownExpanded) { + Icons.TwoTone.KeyboardArrowUp + } else { + Icons.TwoTone.KeyboardArrowDown + }, + summary = summary, + ) + + Box { + DropdownMenu( + expanded = dropDownExpanded, + onDismissRequest = { dropDownExpanded = !dropDownExpanded }, + ) { + items.filterNot { it.first in deprecatedItems }.forEach { item -> + DropdownMenuItem( + onClick = { + dropDownExpanded = false + onItemSelected(item.first) + }, + modifier = modifier + .background( + color = if (selectedItem == item.first) { + MaterialTheme.colors.primary.copy(alpha = 0.3f) + } else { + Color.Unspecified + }, + ), + content = { + Text( + text = item.second, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DropDownPreferencePreview() { + DropDownPreference( + title = "Settings", + summary = "Lorem ipsum dolor sit amet", + enabled = true, + items = listOf("TEST1" to "text1", "TEST2" to "text2"), + selectedItem = "TEST2", + onItemSelected = {} + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt new file mode 100644 index 000000000..1275b1ca1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditBase64Preference.kt @@ -0,0 +1,125 @@ +package com.geeksville.mesh.ui.components + +import android.util.Base64 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Close +import androidx.compose.material.icons.twotone.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.Channel +import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.toByteString + +@Composable +fun EditBase64Preference( + title: String, + value: ByteString, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChange: (ByteString) -> Unit, + modifier: Modifier = Modifier, + onGenerateKey: (() -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, +) { + fun ByteString.encodeToString() = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP) + fun String.toByteString() = Base64.decode(this, Base64.NO_WRAP).toByteString() + + var valueState by remember { mutableStateOf(value.encodeToString()) } + val isError = value.encodeToString() != valueState + + // don't update values while the user is editing + var isFocused by remember { mutableStateOf(false) } + LaunchedEffect(value) { + if (!isFocused) { + valueState = value.encodeToString() + } + } + + val (icon, description) = when { + isError -> Icons.TwoTone.Close to stringResource(R.string.error) + onGenerateKey != null && !isFocused -> Icons.TwoTone.Refresh to stringResource(R.string.reset) + else -> null to null + } + + OutlinedTextField( + value = valueState, + onValueChange = { + valueState = it + runCatching { it.toByteString() }.onSuccess(onValueChange) + }, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { focusState -> isFocused = focusState.isFocused }, + enabled = enabled, + label = { Text(text = title) }, + isError = isError, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + trailingIcon = { + if (trailingIcon != null) { + trailingIcon() + } else if (icon != null) { + IconButton( + onClick = { + if (isError) { + valueState = value.encodeToString() + onValueChange(value) + } else if (onGenerateKey != null && !isFocused) { + onGenerateKey() + } + }, + enabled = enabled, + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = if (isError) { + MaterialTheme.colors.error + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } + ) + } + } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun EditBase64PreferencePreview() { + EditBase64Preference( + title = "Title", + value = Channel.getRandomKey(), + enabled = true, + keyboardActions = KeyboardActions {}, + onValueChange = {}, + onGenerateKey = {}, + modifier = Modifier.padding(16.dp) + ) +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditIPv4Preference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditIPv4Preference.kt similarity index 55% rename from core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditIPv4Preference.kt rename to app/src/main/java/com/geeksville/mesh/ui/components/EditIPv4Preference.kt index e8029615f..239a397f8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditIPv4Preference.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditIPv4Preference.kt @@ -1,20 +1,4 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.ui.component +package com.geeksville.mesh.ui.components import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -39,14 +23,15 @@ fun EditIPv4Preference( ) { val pattern = """\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b""".toRegex() - fun convertIntToIpAddress(int: Int): String = - "${int and 0xff}.${int shr 8 and 0xff}.${int shr 16 and 0xff}.${int shr 24 and 0xff}" + fun convertIntToIpAddress(int: Int): String { + return "${int and 0xff}.${int shr 8 and 0xff}.${int shr 16 and 0xff}.${int shr 24 and 0xff}" + } - fun convertIpAddressToInt(ipAddress: String): Int? = ipAddress - .split(".") - .map { it.toIntOrNull() } - .reversed() // little-endian byte order - .fold(0) { total, next -> if (next == null) return null else total shl 8 or next } + fun convertIpAddressToInt(ipAddress: String): Int? = ipAddress.split(".") + .map { it.toIntOrNull() }.reversed() // little-endian byte order + .fold(0) { total, next -> + if (next == null) return null else total shl 8 or next + } var valueState by remember(value) { mutableStateOf(convertIntToIpAddress(value)) } @@ -55,14 +40,16 @@ fun EditIPv4Preference( value = valueState, enabled = enabled, isError = convertIntToIpAddress(value) != valueState, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ), keyboardActions = keyboardActions, onValueChanged = { valueState = it if (pattern.matches(it)) convertIpAddressToInt(it)?.let { int -> onValueChanged(int) } }, onFocusChanged = {}, - modifier = modifier, + modifier = modifier ) } @@ -74,6 +61,6 @@ private fun EditIPv4PreferencePreview() { value = 16820416, enabled = true, keyboardActions = KeyboardActions {}, - onValueChanged = {}, + onValueChanged = {} ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditListPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditListPreference.kt new file mode 100644 index 000000000..09ffa413b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditListPreference.kt @@ -0,0 +1,196 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePin +import com.geeksville.mesh.ModuleConfigProtos.RemoteHardwarePinType +import com.geeksville.mesh.R +import com.geeksville.mesh.copy +import com.geeksville.mesh.remoteHardwarePin +import com.google.protobuf.ByteString + +@Composable +inline fun EditListPreference( + title: String, + list: List, + maxCount: Int, + enabled: Boolean, + keyboardActions: KeyboardActions, + crossinline onValuesChanged: (List) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val listState = remember(list) { mutableStateListOf().apply { addAll(list) } } + + Column(modifier = modifier) { + Text( + modifier = modifier.padding(16.dp), + text = title, + style = MaterialTheme.typography.body2, + color = if (enabled) { + Color.Unspecified + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + }, + ) + listState.forEachIndexed { index, value -> + val trailingIcon = @Composable { + IconButton( + onClick = { + focusManager.clearFocus() + listState.removeAt(index) + onValuesChanged(listState) + } + ) { + Icon( + imageVector = Icons.TwoTone.Close, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.wrapContentSize(), + ) + } + } + + // handle lora.ignoreIncoming: List + if (value is Int) EditTextPreference( + title = "${index + 1}/$maxCount", + value = value, + enabled = enabled, + keyboardActions = keyboardActions, + onValueChanged = { + listState[index] = it as T + onValuesChanged(listState) + }, + modifier = modifier.fillMaxWidth(), + trailingIcon = trailingIcon, + ) + + // handle security.adminKey: List + if (value is ByteString) EditBase64Preference( + title = "${index + 1}/$maxCount", + value = value, + enabled = enabled, + keyboardActions = keyboardActions, + onValueChange = { + listState[index] = it as T + onValuesChanged(listState) + }, + modifier = modifier.fillMaxWidth(), + trailingIcon = trailingIcon, + ) + + // handle remoteHardware.availablePins: List + if (value is RemoteHardwarePin) { + EditTextPreference( + title = "GPIO pin", + value = value.gpioPin, + enabled = enabled, + keyboardActions = keyboardActions, + onValueChanged = { + if (it in 0..255) { + listState[index] = value.copy { gpioPin = it } as T + onValuesChanged(listState) + } + }, + ) + EditTextPreference( + title = "Name", + value = value.name, + maxSize = 14, // name max_size:15 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + onValueChanged = { + listState[index] = value.copy { name = it } as T + onValuesChanged(listState) + }, + trailingIcon = trailingIcon, + ) + DropDownPreference( + title = "Type", + enabled = enabled, + items = RemoteHardwarePinType.entries + .filter { it != RemoteHardwarePinType.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = value.type, + onItemSelected = { + listState[index] = value.copy { type = it } as T + onValuesChanged(listState) + }, + ) + } + } + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + // Add element based on the type T + val newElement = when (T::class) { + Int::class -> 0 as T + ByteString::class -> ByteString.EMPTY as T + RemoteHardwarePin::class -> remoteHardwarePin {} as T + else -> throw IllegalArgumentException("Unsupported type: ${T::class}") + } + listState.add(listState.size, newElement) + }, + enabled = maxCount > listState.size, + colors = ButtonDefaults.buttonColors( + disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + ) + ) { Text(text = stringResource(R.string.add)) } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditListPreferencePreview() { + Column { + EditListPreference( + title = "Ignore incoming", + list = listOf(12345, 67890), + maxCount = 4, + enabled = true, + keyboardActions = KeyboardActions {}, + onValuesChanged = {}, + ) + EditListPreference( + title = "Available pins", + list = listOf( + remoteHardwarePin { + gpioPin = 12 + name = "Front door" + type = RemoteHardwarePinType.DIGITAL_READ + }, + ), + maxCount = 4, + enabled = true, + keyboardActions = KeyboardActions {}, + onValuesChanged = {}, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt new file mode 100644 index 000000000..b3af71cfb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditPasswordPreference.kt @@ -0,0 +1,72 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import com.geeksville.mesh.R + +@Composable +fun EditPasswordPreference( + title: String, + value: String, + maxSize: Int, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var isPasswordVisible by remember { mutableStateOf(false) } + + EditTextPreference( + title = title, + value = value, + maxSize = maxSize, + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + onValueChanged = { + onValueChanged(it) + }, + onFocusChanged = {}, + visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { + Icon( + painter = if (isPasswordVisible) painterResource(R.drawable.ic_twotone_visibility_off_24) + else painterResource(R.drawable.ic_twotone_visibility_24), + contentDescription = if (isPasswordVisible) "Hide password" else "Show password", + ) + } + }, + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +private fun EditPasswordPreferencePreview() { + EditPasswordPreference( + title = "Password", + value = "top secret", + maxSize = 63, + enabled = true, + keyboardActions = KeyboardActions {}, + onValueChanged = {} + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EditTextPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EditTextPreference.kt new file mode 100644 index 000000000..b95de81eb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EditTextPreference.kt @@ -0,0 +1,219 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +@Composable +fun EditTextPreference( + title: String, + value: Int, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChanged: (Int) -> Unit, + modifier: Modifier = Modifier, + onFocusChanged: (FocusState) -> Unit = {}, + trailingIcon: (@Composable () -> Unit)? = null, +) { + var valueState by remember(value) { mutableStateOf(value.toUInt().toString()) } + + EditTextPreference( + title = title, + value = valueState, + enabled = enabled, + isError = value.toUInt().toString() != valueState, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + onValueChanged = { + if (it.isEmpty()) valueState = it + else it.toUIntOrNull()?.toInt()?.let { int -> + valueState = it + onValueChanged(int) + } + }, + onFocusChanged = onFocusChanged, + modifier = modifier, + trailingIcon = trailingIcon + ) +} + +@Composable +fun EditTextPreference( + title: String, + value: Float, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChanged: (Float) -> Unit, + modifier: Modifier = Modifier, + onFocusChanged: (FocusState) -> Unit = {}, + ) { + var valueState by remember(value) { mutableStateOf(value.toString()) } + + EditTextPreference( + title = title, + value = valueState, + enabled = enabled, + isError = value.toString() != valueState, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + onValueChanged = { + if (it.isEmpty()) valueState = it + else it.toFloatOrNull()?.let { float -> + valueState = it + onValueChanged(float) + } + }, + onFocusChanged = onFocusChanged, + modifier = modifier + ) +} + +@Composable +fun EditTextPreference( + title: String, + value: Double, + enabled: Boolean, + keyboardActions: KeyboardActions, + onValueChanged: (Double) -> Unit, + modifier: Modifier = Modifier, +) { + var valueState by remember(value) { mutableStateOf(value.toString()) } + val decimalSeparators = setOf('.', ',', '٫', '、', '·') // set of possible decimal separators + + EditTextPreference( + title = title, + value = valueState, + enabled = enabled, + isError = value.toString() != valueState, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, imeAction = ImeAction.Done + ), + keyboardActions = keyboardActions, + onValueChanged = { + if (it.length <= 1 || it.first() in decimalSeparators) valueState = it + else it.toDoubleOrNull()?.let { double -> + valueState = it + onValueChanged(double) + } + }, + onFocusChanged = {}, + modifier = modifier + ) +} + +@Composable +fun EditTextPreference( + title: String, + value: String, + enabled: Boolean, + isError: Boolean, + keyboardOptions: KeyboardOptions, + keyboardActions: KeyboardActions, + onValueChanged: (String) -> Unit, + modifier: Modifier = Modifier, + maxSize: Int = 0, // max_size - 1 (in bytes) + onFocusChanged: (FocusState) -> Unit = {}, + trailingIcon: (@Composable () -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + var isFocused by remember { mutableStateOf(false) } + + TextField( + value = value, + singleLine = true, + modifier = modifier + .fillMaxWidth() + .onFocusEvent { isFocused = it.isFocused; onFocusChanged(it) }, + enabled = enabled, + isError = isError, + onValueChange = { + if (maxSize > 0) { + if (it.toByteArray().size <= maxSize) { + onValueChanged(it) + } + } else onValueChanged(it) + }, + label = { Text(title) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + trailingIcon = { + if (trailingIcon != null) { + trailingIcon() + } else if (isError) { + Icon( + imageVector = Icons.TwoTone.Info, + contentDescription = stringResource(id = R.string.error), + tint = MaterialTheme.colors.error + ) + } + }, + ) + + if (maxSize > 0 && isFocused) { + Box( + contentAlignment = Alignment.BottomEnd, + modifier = modifier.fillMaxWidth() + ) { + Text( + text = "${value.toByteArray().size}/$maxSize", + style = MaterialTheme.typography.caption, + color = if (isError) MaterialTheme.colors.error else MaterialTheme.colors.onBackground, + modifier = Modifier.padding(end = 8.dp, bottom = 4.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditTextPreferencePreview() { + Column { + EditTextPreference( + title = "String", + value = "Meshtastic", + maxSize = 39, + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default, + keyboardActions = KeyboardActions {}, + onValueChanged = {}, + ) + EditTextPreference( + title = "Advanced Settings", + value = UInt.MAX_VALUE.toInt(), + enabled = true, + keyboardActions = KeyboardActions {}, + onValueChanged = {} + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt new file mode 100644 index 000000000..f9d706728 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -0,0 +1,466 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT + +private val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue, Color.Green) +private enum class Environment { + TEMPERATURE, + HUMIDITY, + IAQ +} +private val LEGEND_DATA = listOf( + LegendData( + nameRes = R.string.temperature, + color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + isLine = true + ), + LegendData( + nameRes = R.string.humidity, + color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + isLine = true + ), + LegendData( + nameRes = R.string.iaq, + color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + isLine = true + ), +) + +@Composable +fun EnvironmentMetricsScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + /* Convert Celsius to Fahrenheit */ + @Suppress("MagicNumber") + fun celsiusToFahrenheit(celsius: Float): Float { + return (celsius * 1.8F) + 32 + } + + val processedTelemetries: List = if (state.isFahrenheit) { + state.environmentMetrics.map { telemetry -> + val temperatureFahrenheit = + celsiusToFahrenheit(telemetry.environmentMetrics.temperature) + telemetry.copy { + environmentMetrics = + telemetry.environmentMetrics.copy { temperature = temperatureFahrenheit } + } + } + } else { + state.environmentMetrics + } + + var displayInfoDialog by remember { mutableStateOf(false) } + + Column { + + if (displayInfoDialog) { + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.iaq, R.string.iaq_definition) + ), + onDismiss = { displayInfoDialog = false } + ) + } + + EnvironmentMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + telemetries = processedTelemetries.reversed(), + promptInfoDialog = { displayInfoDialog = true } + ) + + /* Environment Metric Cards */ + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(processedTelemetries) { telemetry -> + EnvironmentMetricsCard( + telemetry, + state.isFahrenheit + ) + } + } + } +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +private fun EnvironmentMetricsChart( + modifier: Modifier = Modifier, + telemetries: List, + promptInfoDialog: () -> Unit +) { + ChartHeader(amount = telemetries.size) + if (telemetries.isEmpty()) { + return + } + TimeLabels( + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val transparentTemperatureColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal].copy(alpha = 0.5f) + } + val transparentHumidityColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal].copy(alpha = 0.5f) + } + val transparentIAQColor = remember { + ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal].copy(alpha = 0.5f) + } + val spacing = X_AXIS_SPACING + + /* Since both temperature and humidity are being plotted we need a combined min and max. */ + val (minTemp, maxTemp) = remember(key1 = telemetries) { + Pair( + telemetries.minBy { it.environmentMetrics.temperature }, + telemetries.maxBy { it.environmentMetrics.temperature } + ) + } + val (minHumidity, maxHumidity) = remember(key1 = telemetries) { + Pair( + telemetries.minBy { it.environmentMetrics.relativeHumidity }, + telemetries.maxBy { it.environmentMetrics.relativeHumidity } + ) + } + val (minIAQ, maxIAQ) = remember(key1 = telemetries) { + Pair( + telemetries.minBy { it.environmentMetrics.iaq }, + telemetries.maxBy { it.environmentMetrics.iaq } + ) + } + val min = minOf( + minTemp.environmentMetrics.temperature, + minHumidity.environmentMetrics.relativeHumidity, + minIAQ.environmentMetrics.iaq.toFloat() + ) + val max = maxOf( + maxTemp.environmentMetrics.temperature, + maxHumidity.environmentMetrics.relativeHumidity, + maxIAQ.environmentMetrics.iaq.toFloat() + ) + val diff = max - min + + Box(contentAlignment = Alignment.TopStart) { + ChartOverlay( + modifier = modifier, + labelColor = graphColor, + lineColors = List(size = 5) { graphColor }, + minValue = min, + maxValue = max + ) + + /* Plot Temperature and Relative Humidity */ + Canvas(modifier = modifier) { + val height = size.height + val width = size.width - 28.dp.toPx() + val spacePerEntry = (width - spacing) / telemetries.size + + /* Temperature */ + var lastTempX = 0f + val temperaturePath = Path().apply { + for (i in telemetries.indices) { + val envMetrics = telemetries[i].environmentMetrics + val nextEnvMetrics = + (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics + val leftRatio = (envMetrics.temperature - min) / diff + val rightRatio = (nextEnvMetrics.temperature - min) / diff + + val x1 = spacing + i * spacePerEntry + val y1 = height - (leftRatio * height) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + lastTempX = (x1 + x2) / 2f + quadraticBezierTo( + x1, y1, lastTempX, (y1 + y2) / 2f + ) + } + } + + val fillPath = android.graphics.Path(temperaturePath.asAndroidPath()) + .asComposePath() + .apply { + lineTo(lastTempX, height) + lineTo(spacing, height) + close() + } + + drawPath( + path = fillPath, + brush = Brush.verticalGradient( + colors = listOf( + transparentTemperatureColor, + Color.Transparent + ), + endY = height + ), + ) + + drawPath( + path = temperaturePath, + color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + + /* Relative Humidity */ + var lastHumidityX = 0f + val humidityPath = Path().apply { + for (i in telemetries.indices) { + val envMetrics = telemetries[i].environmentMetrics + val nextEnvMetrics = + (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics + val leftRatio = (envMetrics.relativeHumidity - min) / diff + val rightRatio = (nextEnvMetrics.relativeHumidity - min) / diff + + val x1 = spacing + i * spacePerEntry + val y1 = height - (leftRatio * height) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + lastHumidityX = (x1 + x2) / 2f + quadraticBezierTo( + x1, y1, lastHumidityX, (y1 + y2) / 2f + ) + } + } + + val fillHumidityPath = android.graphics.Path(humidityPath.asAndroidPath()) + .asComposePath() + .apply { + lineTo(lastHumidityX, height) + lineTo(spacing, height) + close() + } + + drawPath( + path = fillHumidityPath, + brush = Brush.verticalGradient( + colors = listOf( + transparentHumidityColor, + Color.Transparent + ), + endY = height + ), + ) + + drawPath( + path = humidityPath, + color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + + /* Air Quality */ + var lastIaqX = 0f + val iaqPath = Path().apply { + for (i in telemetries.indices) { + val envMetrics = telemetries[i].environmentMetrics + val nextEnvMetrics = + (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics + val leftRatio = (envMetrics.iaq - min) / diff + val rightRatio = (nextEnvMetrics.iaq - min) / diff + + val x1 = spacing + i * spacePerEntry + val y1 = height - (leftRatio * height) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + lastIaqX = (x1 + x2) / 2f + quadraticBezierTo( + x1, + y1, + lastIaqX, + (y1 + y2) / 2f + ) + } + } + + val fillIaqPath = android.graphics.Path(iaqPath.asAndroidPath()) + .asComposePath() + .apply { + lineTo(lastIaqX, height) + lineTo(spacing, height) + close() + } + drawPath( + path = fillIaqPath, + brush = Brush.verticalGradient( + colors = listOf( + transparentIAQColor, + Color.Transparent + ), + endY = height + ), + ) + + drawPath( + path = iaqPath, + color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Legend(LEGEND_DATA, promptInfoDialog) + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { + val envMetrics = telemetry.environmentMetrics + val time = telemetry.time * MS_PER_SEC + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + /* Time and Temperature */ + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C" + Text( + text = textFormat.format( + stringResource(id = R.string.temperature), + envMetrics.temperature + ), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + /* Humidity and Barometric Pressure */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "%s %.2f%%".format( + stringResource(id = R.string.humidity), + envMetrics.relativeHumidity, + ), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + if (envMetrics.barometricPressure > 0) { + Text( + text = "%.2f hPa".format(envMetrics.barometricPressure), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + if (telemetry.environmentMetrics.hasIaq()) { + Spacer(modifier = Modifier.height(4.dp)) + /* Air Quality */ + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + + ) { + Text( + text = stringResource(R.string.iaq), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + Spacer(modifier = Modifier.width(4.dp)) + IndoorAirQuality( + iaq = telemetry.environmentMetrics.iaq, + displayMode = IaqDisplayMode.Dot + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/IndoorAirQuality.kt b/app/src/main/java/com/geeksville/mesh/ui/components/IndoorAirQuality.kt new file mode 100644 index 000000000..78cbc8a75 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/IndoorAirQuality.kt @@ -0,0 +1,305 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.geeksville.mesh.R + +@Suppress("MagicNumber") +enum class Iaq(val color: Color, val description: String, val range: IntRange) { + Excellent(Color(0xFF00E400), "Excellent", 0..50), + Good(Color(0xFF92D050), "Good", 51..100), + LightlyPolluted(Color(0xFFFFFF00), "Lightly Polluted", 101..150), + ModeratelyPolluted(Color(0xFFFF7300), "Moderately Polluted", 151..200), + HeavilyPolluted(Color(0xFFFF0000), "Heavily Polluted", 201..300), + SeverelyPolluted(Color(0xFF99004C), "Severely Polluted", 301..400), + ExtremelyPolluted(Color(0xFF663300), "Extremely Polluted", 401..500), + DangerouslyPolluted(Color(0xFF663300), "Dangerously Polluted", 501..Int.MAX_VALUE) +} + +fun getIaq(iaq: Int): Iaq { + return when { + iaq in Iaq.Excellent.range -> Iaq.Excellent + iaq in Iaq.Good.range -> Iaq.Good + iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted + iaq in Iaq.ModeratelyPolluted.range -> Iaq.ModeratelyPolluted + iaq in Iaq.HeavilyPolluted.range -> Iaq.HeavilyPolluted + iaq in Iaq.SeverelyPolluted.range -> Iaq.SeverelyPolluted + iaq in Iaq.ExtremelyPolluted.range -> Iaq.ExtremelyPolluted + else -> Iaq.DangerouslyPolluted + } +} + +private fun getIaqDescriptionWithRange(iaqEnum: Iaq): String { + return if (iaqEnum.range.last == Int.MAX_VALUE) { + "${iaqEnum.description} (${iaqEnum.range.first}+)" + } else { + "${iaqEnum.description} (${iaqEnum.range.first}-${iaqEnum.range.last})" + } +} + +enum class IaqDisplayMode { + Pill, Dot, Text, Gauge, Gradient +} + +@Suppress("LongMethod", "UnusedPrivateProperty") +@Composable +fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) { + var isLegendOpen by remember { mutableStateOf(false) } + val iaqEnum = getIaq(iaq) + val gradient = Brush.linearGradient( + colors = Iaq.entries.map { it.color }, + ) + + Column { + when (displayMode) { + IaqDisplayMode.Pill -> { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(iaqEnum.color) + .width(125.dp) + .height(30.dp) + .clickable { isLegendOpen = true } + ) { + Row( + modifier = Modifier + .padding(4.dp) + .align(Alignment.CenterStart), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "IAQ $iaq", + color = Color.White, + fontWeight = FontWeight.Bold + ) + Icon( + imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning, + contentDescription = "AQI Icon", + tint = Color.White + ) + } + } + } + + IaqDisplayMode.Dot -> { + Column(modifier = Modifier.clickable { isLegendOpen = true }) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "$iaq") + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .size(10.dp) + .background(iaqEnum.color, shape = CircleShape) + ) + } + } + } + + IaqDisplayMode.Text -> { + Text( + text = getIaqDescriptionWithRange(iaqEnum), + fontSize = 12.sp, + modifier = Modifier.clickable { isLegendOpen = true } + ) + } + + IaqDisplayMode.Gauge -> { + CircularProgressIndicator( + progress = iaq / 500f, + modifier = Modifier + .size(60.dp) + .clickable { isLegendOpen = true }, + strokeWidth = 8.dp, + color = iaqEnum.color + ) + Text(text = "$iaq") + } + + IaqDisplayMode.Gradient -> { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.clickable { isLegendOpen = true } + ) { + LinearProgressIndicator( + progress = iaq / 500f, + modifier = Modifier + .fillMaxWidth() + .height(20.dp), + color = iaqEnum.color, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = iaqEnum.description, fontSize = 12.sp) + } + } + } + if (isLegendOpen) { + AlertDialog( + onDismissRequest = { isLegendOpen = false }, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + text = { + IAQScale() + }, + confirmButton = { + TextButton(onClick = { isLegendOpen = false }) { + Text(text = stringResource(id = R.string.close)) + } + } + ) + } + } +} + +// Assuming Iaq is an enum class with color and description properties +// and that it conforms to CaseIterable. +// Replace with your actual implementation + +@Composable +fun IAQScale(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding(16.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = "Indoor Air Quality (IAQ)", + style = MaterialTheme.typography.h6.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + for (iaq in Iaq.entries) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(20.dp, 15.dp) + .clip(RoundedCornerShape(5.dp)) + .background(iaq.color) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(getIaqDescriptionWithRange(iaq), style = MaterialTheme.typography.body2) + } + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun IAQScalePreview() { + IAQScale() +} + +@Suppress("LongMethod") +@Preview(showBackground = true) +@Composable +private fun IndoorAirQualityPreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Pill", style = MaterialTheme.typography.h6) + Row { + IndoorAirQuality(iaq = 6) + IndoorAirQuality(iaq = 51) + } + Row { + IndoorAirQuality(iaq = 101) + IndoorAirQuality(iaq = 201) + } + Row { + IndoorAirQuality(iaq = 350) + IndoorAirQuality(iaq = 351) + } + + Text("Dot", style = MaterialTheme.typography.h6) + Row { + IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Dot) + IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Dot) + IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Dot) + IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Dot) + IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Dot) + IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Dot) + } + + Text("Text", style = MaterialTheme.typography.h6) + Row { + IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Text) + IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Text) + IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Text) + } + Row { + IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Text) + IndoorAirQuality(iaq = 350, displayMode = IaqDisplayMode.Text) + IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Text) + } + + Text("Gauge", style = MaterialTheme.typography.h6) + Row { + IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 151, displayMode = IaqDisplayMode.Gauge) + } + Row { + IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 251, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 301, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gauge) + } + Row { + IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gauge) + IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gauge) + } + + Text("Gradient", style = MaterialTheme.typography.h6) + IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 201, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 401, displayMode = IaqDisplayMode.Gradient) + IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gradient) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LazyColumnDragAndDropDemo.kt b/app/src/main/java/com/geeksville/mesh/ui/components/LazyColumnDragAndDropDemo.kt similarity index 63% rename from core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LazyColumnDragAndDropDemo.kt rename to app/src/main/java/com/geeksville/mesh/ui/components/LazyColumnDragAndDropDemo.kt index b6ffd6e9c..64d35cee2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LazyColumnDragAndDropDemo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/LazyColumnDragAndDropDemo.kt @@ -1,23 +1,24 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * Copyright 2021 The Android Open Source Project * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. + * http://www.apache.org/licenses/LICENSE-2.0 * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ -package org.meshtastic.core.ui.component + +package com.geeksville.mesh.ui.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.scrollBy @@ -34,8 +35,8 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Card -import androidx.compose.material3.Text +import androidx.compose.material.Card +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -58,42 +59,45 @@ import androidx.compose.ui.zIndex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.preview_footer -import org.meshtastic.core.resources.preview_header -import org.meshtastic.core.resources.preview_item -// Derived in part from: -// https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt +// Derived in part from: https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt @Preview @Composable fun LazyColumnDragAndDropDemo() { var list by remember { mutableStateOf(List(50) { it }) } val listState = rememberLazyListState() - val dragDropState = - rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex -> - if (fromIndex in list.indices && toIndex in list.indices) { - list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } - } + val dragDropState = rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex -> + if (fromIndex in list.indices && toIndex in list.indices) { + list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } } + } LazyColumn( - modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current), + modifier = Modifier.dragContainer( + dragDropState = dragDropState, + haptics = LocalHapticFeedback.current, + ), state = listState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - item { Text(stringResource(Res.string.preview_header), Modifier.fillMaxWidth().padding(20.dp)) } + item { + Text("Header", Modifier.fillMaxWidth().padding(20.dp)) + } itemsIndexed(list, key = { _, item -> item }) { index, item -> - DraggableItem(dragDropState, index + 1) { - Card { Text(stringResource(Res.string.preview_item, item), Modifier.fillMaxWidth().padding(20.dp)) } + DraggableItem(dragDropState, index + 1) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 1.dp) + Card(elevation = elevation) { + Text("Item $item", Modifier.fillMaxWidth().padding(20.dp)) + } } } - item { Text(stringResource(Res.string.preview_footer), Modifier.fillMaxWidth().padding(20.dp)) } + item { + Text("Footer", Modifier.fillMaxWidth().padding(20.dp)) + } } } @@ -101,7 +105,7 @@ fun LazyColumnDragAndDropDemo() { fun rememberDragDropState( lazyListState: LazyListState, headerCount: Int = 0, - onMove: (Int, Int) -> Unit, + onMove: (Int, Int) -> Unit ): DragDropState { val scope = rememberCoroutineScope() val state = remember(lazyListState) { DragDropState(lazyListState, headerCount, scope, onMove) } @@ -119,20 +123,19 @@ internal constructor( private val state: LazyListState, private val headerCount: Int, private val scope: CoroutineScope, - private val onMove: (Int, Int) -> Unit, + private val onMove: (Int, Int) -> Unit ) { private var draggingItemIndex by mutableStateOf(null) - val adjustedItemIndex - get() = draggingItemIndex?.minus(headerCount) + val adjustedItemIndex get() = draggingItemIndex?.minus(headerCount) internal val scrollChannel = Channel() private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemInitialOffset by mutableIntStateOf(0) internal val draggingItemOffset: Float - get() = - draggingItemLayoutInfo?.let { item -> draggingItemInitialOffset + draggingItemDraggedDelta - item.offset } - ?: 0f + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f private val draggingItemLayoutInfo: LazyListItemInfo? get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } @@ -144,7 +147,7 @@ internal constructor( private set internal fun onDragStart(offset: Offset): LazyListItemInfo? = state.layoutInfo.visibleItemsInfo - .filter { it.contentType == DRAG_DROP_CONTENT_TYPE } + .filter { it.contentType == DragDropContentType } .firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } ?.also { draggingItemIndex = it.index @@ -159,7 +162,7 @@ internal constructor( previousItemOffset.snapTo(startOffset) previousItemOffset.animateTo( 0f, - spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f), + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) ) previousIndexOfDraggedItem = null } @@ -177,26 +180,33 @@ internal constructor( val endOffset = startOffset + draggingItem.size val middleOffset = startOffset + (endOffset - startOffset) / 2f - val targetItem = - state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index + val targetItem = state.layoutInfo.visibleItemsInfo + .find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index } if (targetItem != null) { - if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) { - state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset) + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset + ) } onMove.invoke(draggingItem.index - headerCount, targetItem.index - headerCount) draggingItemIndex = targetItem.index } else { - val overscroll = - when { - draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + val overscroll = when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) - draggingItemDraggedDelta < 0 -> - (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) - else -> 0f - } + else -> 0f + } if (overscroll != 0f) { scrollChannel.trySend(overscroll) } @@ -207,7 +217,10 @@ internal constructor( get() = this.offset + this.size } -fun Modifier.dragContainer(dragDropState: DragDropState, haptics: HapticFeedback): Modifier { +fun Modifier.dragContainer( + dragDropState: DragDropState, + haptics: HapticFeedback, +): Modifier { return this.pointerInput(dragDropState) { detectDragGesturesAfterLongPress( onDrag = { change, offset -> @@ -219,7 +232,7 @@ fun Modifier.dragContainer(dragDropState: DragDropState, haptics: HapticFeedback haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, onDragEnd = { dragDropState.onDragInterrupted() }, - onDragCancel = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } ) } } @@ -229,42 +242,45 @@ fun LazyItemScope.DraggableItem( dragDropState: DragDropState, index: Int, modifier: Modifier = Modifier, - content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit ) { val dragging = index == dragDropState.adjustedItemIndex - val draggingModifier = - if (dragging) { - Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } - } else if (index == dragDropState.previousIndexOfDraggedItem) { - Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.previousItemOffset.value } - } else { - Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) - } + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { translationY = dragDropState.previousItemOffset.value } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } Column(modifier = modifier.then(draggingModifier)) { content(dragging) } } -const val DRAG_DROP_CONTENT_TYPE = "drag-and-drop" +const val DragDropContentType = "drag-and-drop" /** * Extension function for [LazyListScope] with drag-and-drop functionality for indexed items. * - * Wraps [itemsIndexed] function with [detectDragGesturesAfterLongPress] to enable long-press drag gestures and allow - * items in the list to be reordered using the provided [DragDropState]. + * Wraps [itemsIndexed] function with [detectDragGesturesAfterLongPress] to enable long-press + * drag gestures and allow items in the list to be reordered using the provided [DragDropState]. */ inline fun LazyListScope.dragDropItemsIndexed( items: List, dragDropState: DragDropState, noinline key: ((index: Int, item: T) -> Any)? = null, - crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T, isDragging: Boolean) -> Unit, + crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T, isDragging: Boolean) -> Unit ) = itemsIndexed( items = items, key = key, - contentType = { _, _ -> DRAG_DROP_CONTENT_TYPE }, + contentType = { _, _ -> DragDropContentType }, itemContent = { index, item -> DraggableItem( dragDropState = dragDropState, index = index, - content = { isDragging -> itemContent(index, item, isDragging) }, + content = { isDragging -> itemContent(index, item, isDragging) } ) - }, + } ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt b/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt new file mode 100644 index 000000000..6f6f42629 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/LoraSignalIndicator.kt @@ -0,0 +1,107 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SignalCellular4Bar +import androidx.compose.material.icons.filled.SignalCellularAlt +import androidx.compose.material.icons.filled.SignalCellularAlt1Bar +import androidx.compose.material.icons.filled.SignalCellularAlt2Bar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +private const val SNR_GOOD_THRESHOLD = -7f +private const val SNR_FAIR_THRESHOLD = -15f + +private const val RSSI_GOOD_THRESHOLD = -115 +private const val RSSI_FAIR_THRESHOLD = -126 + +private enum class Quality( + val nameRes: Int, + val imageVector: ImageVector, + val color: Color +) { + NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, Color.Red), + BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, Color(red = 247, green = 147, blue = 26)), + FAIR(R.string.fair, Icons.Default.SignalCellularAlt, Color(red = 255, green = 230, blue = 0)), + GOOD(R.string.good, Icons.Default.SignalCellular4Bar, Color.Green) +} + +@Composable +fun Snr(snr: Float) { + val color: Color = if (snr > SNR_GOOD_THRESHOLD) { + Quality.GOOD.color + } else if (snr > SNR_FAIR_THRESHOLD) { + Quality.FAIR.color + } else { + Quality.BAD.color + } + + Text( + text = "%s %.2fdB".format( + stringResource(id = R.string.snr), + snr + ), + color = color, + fontSize = MaterialTheme.typography.button.fontSize + ) +} + +@Composable +fun Rssi(rssi: Int) { + val color: Color = if (rssi > RSSI_GOOD_THRESHOLD) { + Quality.GOOD.color + } else if (rssi > RSSI_FAIR_THRESHOLD) { + Quality.FAIR.color + } else { + Quality.BAD.color + } + Text( + text = "%s %ddB".format( + stringResource(id = R.string.rssi), + rssi + ), + color = color, + fontSize = MaterialTheme.typography.button.fontSize + ) +} + +@Composable +fun LoraSignalIndicator(snr: Float, rssi: Int) { + + val quality = determineSignalQuality(snr, rssi) + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + Icon( + imageVector = quality.imageVector, + contentDescription = stringResource(R.string.signal_quality), + tint = quality.color + ) + Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}") + } +} + +private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when { + snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD + snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR + snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR + snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE + else -> Quality.BAD +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt new file mode 100644 index 000000000..8b66bd0b5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeFilterTextField.kt @@ -0,0 +1,223 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.NodeSortOption +import com.geeksville.mesh.ui.theme.AppTheme + +@Composable +fun NodeFilterTextField( + modifier: Modifier = Modifier, + filterText: String, + onTextChange: (String) -> Unit, + currentSortOption: NodeSortOption, + onSortSelect: (NodeSortOption) -> Unit, + includeUnknown: Boolean, + onToggleIncludeUnknown: () -> Unit, + showDetails: Boolean, + onToggleShowDetails: () -> Unit, +) { + Row( + modifier = modifier.background(MaterialTheme.colors.background), + ) { + NodeFilterTextField( + filterText = filterText, + onTextChange = onTextChange, + modifier = Modifier.weight(1f) + ) + + NodeSortButton( + currentSortOption = currentSortOption, + onSortSelect = onSortSelect, + includeUnknown = includeUnknown, + onToggleIncludeUnknown = onToggleIncludeUnknown, + showDetails = showDetails, + onToggleShowDetails = onToggleShowDetails, + ) + } +} + +@Composable +private fun NodeFilterTextField( + filterText: String, + onTextChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + var isFocused by remember { mutableStateOf(false) } + + OutlinedTextField( + modifier = modifier + .heightIn(max = 48.dp) + .onFocusEvent { isFocused = it.isFocused }, + value = filterText, + placeholder = { + Text( + text = stringResource(id = R.string.node_filter_placeholder), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.35F) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = stringResource(id = R.string.node_filter_placeholder), + ) + }, + onValueChange = onTextChange, + trailingIcon = { + if (filterText.isNotEmpty() || isFocused) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(id = R.string.desc_node_filter_clear), + modifier = Modifier.clickable { + onTextChange("") + focusManager.clearFocus() + } + ) + } + }, + textStyle = TextStyle( + color = MaterialTheme.colors.onBackground + ), + maxLines = 1, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() } + ) + ) +} + +@Suppress("LongMethod") +@Composable +private fun NodeSortButton( + currentSortOption: NodeSortOption, + onSortSelect: (NodeSortOption) -> Unit, + includeUnknown: Boolean, + onToggleIncludeUnknown: () -> Unit, + showDetails: Boolean, + onToggleShowDetails: () -> Unit, + modifier: Modifier = Modifier, +) = Box(modifier) { + var expanded by remember { mutableStateOf(false) } + + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_sort_24), + contentDescription = null, + modifier = Modifier.heightIn(max = 48.dp), + tint = MaterialTheme.colors.onSurface + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colors.background.copy(alpha = 1f)) + ) { + NodeSortOption.entries.forEach { sort -> + DropdownMenuItem( + onClick = { + onSortSelect(sort) + expanded = false + }, + ) { + Text( + text = stringResource(id = sort.stringRes), + fontWeight = if (sort == currentSortOption) FontWeight.Bold else null, + ) + } + } + Divider() + DropdownMenuItem( + onClick = { + onToggleIncludeUnknown() + expanded = false + }, + ) { + Text( + text = stringResource(id = R.string.node_filter_include_unknown), + ) + AnimatedVisibility(visible = includeUnknown) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + Divider() + DropdownMenuItem( + onClick = { + onToggleShowDetails() + expanded = false + }, + ) { + Text( + text = stringResource(id = R.string.node_filter_show_details), + ) + AnimatedVisibility(visible = showDetails) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun NodeFilterTextFieldPreview() { + AppTheme { + NodeFilterTextField( + filterText = "Filter text", + onTextChange = {}, + currentSortOption = NodeSortOption.LAST_HEARD, + onSortSelect = {}, + includeUnknown = false, + onToggleIncludeUnknown = {}, + showDetails = false, + onToggleShowDetails = {}, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeKeyStatusIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeKeyStatusIcon.kt new file mode 100644 index 000000000..9cb12495a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeKeyStatusIcon.kt @@ -0,0 +1,42 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.geeksville.mesh.R + +@Composable +fun NodeKeyStatusIcon( + hasPKC: Boolean, + mismatchKey: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) = IconButton( + onClick = onClick, + modifier = modifier, +) { + val (icon, tint) = when { + mismatchKey -> rememberVectorPainter(Icons.Default.KeyOff) to Color.Red + hasPKC -> rememberVectorPainter(Icons.Default.Lock) to Color(color = 0xFF30C047) + else -> painterResource(R.drawable.ic_lock_open_right_24) to Color(color = 0xFFFEC30A) + } + Icon( + painter = icon, + contentDescription = stringResource( + id = when { + mismatchKey -> R.string.encryption_error + hasPKC -> R.string.encryption_pkc + else -> R.string.encryption_psk + } + ), + tint = tint, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt new file mode 100644 index 000000000..426eccd84 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMap.kt @@ -0,0 +1,45 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle +import com.geeksville.mesh.util.addCopyright +import com.geeksville.mesh.util.addPolyline +import com.geeksville.mesh.util.addPositionMarkers +import com.geeksville.mesh.util.addScaleBarOverlay +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint + +private const val DegD = 1e-7 + +@Composable +fun NodeMapScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val density = LocalDensity.current + val state by viewModel.state.collectAsStateWithLifecycle() + val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) } + val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } + val mapView = rememberMapViewWithLifecycle(cameraView) + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { mapView }, + update = { map -> + map.overlays.clear() + map.addCopyright() + map.addScaleBarOverlay(density) + + map.addPolyline(density, geoPoints) {} + map.addPositionMarkers(state.positionLogs) {} + } + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt new file mode 100644 index 000000000..1cc4c6cfc --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PositionLog.kt @@ -0,0 +1,286 @@ +package com.geeksville.mesh.ui.components + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Save +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.metersIn +import com.geeksville.mesh.util.toString +import java.text.DateFormat + +@Composable +private fun RowScope.PositionText(text: String, weight: Float) { + Text( + text = text, + modifier = Modifier.weight(weight), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) +} + +private const val Weight10 = .10f +private const val Weight15 = .15f +private const val Weight20 = .20f +private const val Weight40 = .40f + +@Composable +private fun HeaderItem(compactWidth: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PositionText("Latitude", Weight20) + PositionText("Longitude", Weight20) + PositionText("Sats", Weight10) + PositionText("Alt", Weight15) + if (!compactWidth) { + PositionText("Speed", Weight15) + PositionText("Heading", Weight15) + } + PositionText("Timestamp", Weight40) + } +} + +private const val DegD = 1e-7 +private const val HeadingDeg = 1e-5 +private const val SecondsToMillis = 1000L + +@Composable +private fun PositionItem( + compactWidth: Boolean, + position: MeshProtos.Position, + dateFormat: DateFormat, + system: DisplayUnits, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PositionText("%.5f".format(position.latitudeI * DegD), Weight20) + PositionText("%.5f".format(position.longitudeI * DegD), Weight20) + PositionText(position.satsInView.toString(), Weight10) + PositionText(position.altitude.metersIn(system).toString(system), Weight15) + if (!compactWidth) { + PositionText("${position.groundSpeed} Km/h", Weight15) + PositionText("%.0f°".format(position.groundTrack * HeadingDeg), Weight15) + } + PositionText(dateFormat.format(position.time * SecondsToMillis), Weight40) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ActionButtons( + clearButtonEnabled: Boolean, + onClear: () -> Unit, + saveButtonEnabled: Boolean, + onSave: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onClear, + enabled = clearButtonEnabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.error, + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(id = R.string.clear), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.clear), + ) + } + + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onSave, + enabled = saveButtonEnabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + ) + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = stringResource(id = R.string.save), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.save), + ) + } + } +} + +@Composable +fun PositionLogScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + val exportPositionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) } + } + } + + var clearButtonEnabled by rememberSaveable(state.positionLogs) { + mutableStateOf(state.positionLogs.isNotEmpty()) + } + + BoxWithConstraints { + val compactWidth = maxWidth < 600.dp + Column { + val textStyle = if (compactWidth) { + MaterialTheme.typography.caption + } else { + LocalTextStyle.current + } + CompositionLocalProvider(LocalTextStyle provides textStyle) { + HeaderItem(compactWidth) + PositionList(compactWidth, state.positionLogs, state.displayUnits) + } + + ActionButtons( + clearButtonEnabled = clearButtonEnabled, + onClear = { + clearButtonEnabled = false + viewModel.clearPosition() + }, + saveButtonEnabled = state.hasPositionLogs(), + onSave = { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/*" + putExtra(Intent.EXTRA_TITLE, "position.csv") + } + exportPositionLauncher.launch(intent) + }, + ) + } + } +} + +@Composable +private fun ColumnScope.PositionList( + compactWidth: Boolean, + positions: List, + displayUnits: DisplayUnits, +) { + val dateFormat = remember { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + } + + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + LazyColumn( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(positions) { position -> + PositionItem(compactWidth, position, dateFormat, displayUnits) + } + } + } +} + +@Suppress("MagicNumber") +private val testPosition = MeshProtos.Position.newBuilder().apply { + latitudeI = 297604270 + longitudeI = -953698040 + altitude = 1230 + satsInView = 7 + time = (System.currentTimeMillis() / 1000).toInt() +}.build() + +@Preview(showBackground = true) +@Composable +private fun PositionItemPreview() { + AppTheme { + PositionItem( + compactWidth = false, + position = testPosition, + dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM), + system = DisplayUnits.METRIC, + ) + } +} + +@PreviewScreenSizes +@Composable +private fun ActionButtonsPreview() { + AppTheme { + Column(Modifier.fillMaxSize(), Arrangement.Bottom) { + ActionButtons( + clearButtonEnabled = true, + onClear = {}, + saveButtonEnabled = true, + onSave = {}, + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PositionPrecisionPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PositionPrecisionPreference.kt new file mode 100644 index 000000000..f7557eb49 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PositionPrecisionPreference.kt @@ -0,0 +1,101 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.util.DistanceUnit +import com.geeksville.mesh.util.toDistanceString +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt + +private const val PositionEnabled = 32 +private const val PositionDisabled = 0 + +const val PositionPrecisionMin = 10 +const val PositionPrecisionMax = 19 +const val PositionPrecisionDefault = 13 + +@Suppress("MagicNumber") +fun precisionBitsToMeters(bits: Int): Double = 23905787.925008 * 0.5.pow(bits.toDouble()) + +@Composable +fun PositionPrecisionPreference( + title: String, + value: Int, + enabled: Boolean, + onValueChanged: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val unit = remember { DistanceUnit.getFromLocale(Locale.getDefault()) } + + Column(modifier = modifier) { + SwitchPreference( + title = title, + checked = value != PositionDisabled, + enabled = enabled, + onCheckedChange = { enabled -> + val newValue = if (enabled) PositionEnabled else PositionDisabled + onValueChanged(newValue) + }, + padding = PaddingValues(0.dp) + ) + AnimatedVisibility(visible = value != PositionDisabled) { + SwitchPreference( + title = "Precise location", + checked = value == PositionEnabled, + enabled = enabled, + onCheckedChange = { enabled -> + val newValue = if (enabled) PositionEnabled else PositionPrecisionDefault + onValueChanged(newValue) + }, + padding = PaddingValues(0.dp) + ) + } + AnimatedVisibility(visible = value in (PositionDisabled + 1).. Unit)? = null +) { + Text( + text, + modifier = modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp), + style = MaterialTheme.typography.h6, + ) + if (content != null) { + Surface( + modifier = modifier.padding(bottom = 8.dp), + shape = RoundedCornerShape(12.dp), + elevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProvideTextStyle(MaterialTheme.typography.body1) { + content() + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreferenceCategoryPreview() { + PreferenceCategory( + text = "Advanced settings" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceFooter.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceFooter.kt new file mode 100644 index 000000000..9bfea6f9c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceFooter.kt @@ -0,0 +1,84 @@ +package com.geeksville.mesh.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +@Composable +fun PreferenceFooter( + enabled: Boolean, + onCancelClicked: () -> Unit, + onSaveClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceFooter( + enabled = enabled, + negativeText = R.string.cancel, + onNegativeClicked = onCancelClicked, + positiveText = R.string.send, + onPositiveClicked = onSaveClicked, + modifier = modifier, + ) +} + +@Composable +fun PreferenceFooter( + enabled: Boolean, + @StringRes negativeText: Int, + onNegativeClicked: () -> Unit, + @StringRes positiveText: Int, + onPositiveClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(64.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = modifier + .height(48.dp) + .weight(1f), + enabled = enabled, + onClick = onNegativeClicked, + colors = ButtonDefaults.buttonColors( + disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + ) + ) { + Text( + text = stringResource(id = negativeText), + style = MaterialTheme.typography.body1, + ) + } + OutlinedButton( + modifier = modifier + .height(48.dp) + .weight(1f), + enabled = enabled, + onClick = onPositiveClicked, + colors = ButtonDefaults.buttonColors( + disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + ) + ) { + Text( + text = stringResource(id = positiveText), + style = MaterialTheme.typography.body1, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreferenceFooterPreview() { + PreferenceFooter(enabled = true, onCancelClicked = {}, onSaveClicked = {}) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/RegularPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/RegularPreference.kt new file mode 100644 index 000000000..558b6e3ca --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/RegularPreference.kt @@ -0,0 +1,124 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun RegularPreference( + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + summary: String? = null, + trailingIcon: ImageVector? = null, +) { + RegularPreference( + title = title, + subtitle = AnnotatedString(text = subtitle), + onClick = onClick, + modifier = modifier, + enabled = enabled, + summary = summary, + trailingIcon = trailingIcon, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RegularPreference( + title: String, + subtitle: AnnotatedString, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + summary: String? = null, + trailingIcon: ImageVector? = null, +) { + val color = if (enabled) { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + } + + Column( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(all = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FlowRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.body1, + color = if (enabled) { + Color.Unspecified + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + }, + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.body1, + color = color, + ) + } + if (trailingIcon != null) { + Icon( + imageVector = trailingIcon, + contentDescription = "trailingIcon", + modifier = Modifier + .padding(start = 8.dp) + .wrapContentWidth(Alignment.End), + tint = color, + ) + } + } + if (summary != null) { + Divider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = summary, + style = MaterialTheme.typography.body2, + color = color, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RegularPreferencePreview() { + RegularPreference( + title = "Advanced settings", + subtitle = "Text2", + onClick = { }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt new file mode 100644 index 000000000..7fcc173c9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/ScannedQrCodeDialog.kt @@ -0,0 +1,201 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.geeksville.mesh.AppOnlyProtos.ChannelSet +import com.geeksville.mesh.R +import com.geeksville.mesh.channelSet +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.ui.components.config.ChannelSelection + +/** + * Enables the user to select which channels to accept after scanning a QR code. + */ +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +@Composable +fun ScannedQrCodeDialog( + channels: ChannelSet, + incoming: ChannelSet, + onDismiss: () -> Unit, + onConfirm: (ChannelSet) -> Unit +) { + var shouldReplace by remember { mutableStateOf(true) } + + val channelSet = remember(shouldReplace) { + if (shouldReplace) { + incoming + } else { + channels.copy { + val result = LinkedHashSet(settings + incoming.settingsList) + settings.clear() + settings.addAll(result) + } + } + } + + val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name + + /* Holds selections made by the user */ + val channelSelections = remember(channelSet) { + mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true })) + } + + val selectedChannelSet = channelSet.copy { + val result = settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true } + settings.clear() + settings.addAll(result) + } + + Dialog( + onDismissRequest = { onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true) + ) { + Surface( + modifier = Modifier.widthIn(max = 600.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colors.background + ) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + text = stringResource(id = R.string.new_channel_rcvd), + modifier = Modifier.padding(20.dp), + style = MaterialTheme.typography.h6, + ) + } + itemsIndexed(channelSet.settingsList) { index, channel -> + ChannelSelection( + index = index, + title = channel.name.ifEmpty { modemPresetName }, + enabled = true, + isSelected = channelSelections[index], + onSelected = { + if (it || selectedChannelSet.settingsCount > 1) { + channelSelections[index] = it + } + }, + ) + } + + item { + Row( + modifier = Modifier.padding(vertical = 20.dp), + ) { + val selectedColors = ButtonDefaults.buttonColors() + val unselectedColors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ) + + OutlinedButton( + onClick = { shouldReplace = false }, + modifier = Modifier + .height(48.dp) + .weight(1f), + colors = if (!shouldReplace) selectedColors else unselectedColors, + ) { Text(text = stringResource(R.string.add)) } + + OutlinedButton( + onClick = { shouldReplace = true }, + modifier = Modifier + .height(48.dp) + .weight(1f), + colors = if (shouldReplace) selectedColors else unselectedColors, + ) { Text(text = stringResource(R.string.replace)) } + } + } + + /* User Actions via buttons */ + item { + FlowRow( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + TextButton( + onClick = { + onDismiss() + }, + ) { + Text( + text = stringResource(id = R.string.cancel), + color = MaterialTheme.colors.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.body1, + ) + } + + TextButton( + onClick = { + onDismiss() + onConfirm(selectedChannelSet) + }, + enabled = selectedChannelSet.settingsCount in 1..8, + ) { + Text( + text = stringResource(id = R.string.accept), + color = MaterialTheme.colors.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.body1, + ) + } + } + } + } + } + } +} + +@PreviewScreenSizes +@Composable +private fun ScannedQrCodeDialogPreview() { + ScannedQrCodeDialog( + channels = channelSet { + settings.add(Channel.default.settings) + loraConfig = Channel.default.loraConfig + }, + incoming = channelSet { + settings.add(Channel.default.settings) + loraConfig = Channel.default.loraConfig + }, + onDismiss = {}, + onConfirm = {}, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt new file mode 100644 index 000000000..f1972676f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -0,0 +1,280 @@ +package com.geeksville.mesh.ui.components + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Surface +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.MeshProtos.MeshPacket +import com.geeksville.mesh.R +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT +import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA +import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT + +private val METRICS_COLORS = listOf(Color.Green, Color.Blue) + +@Suppress("MagicNumber") +private enum class Metric(val min: Float, val max: Float) { + SNR(-20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */ + RSSI(-140f, -20f); + /** + * Difference between the metrics `max` and `min` values. + */ + fun difference() = max - min +} +private val LEGEND_DATA = listOf( + LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]), + LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]) +) + +@Composable +fun SignalMetricsScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var displayInfoDialog by remember { mutableStateOf(false) } + + Column { + + if (displayInfoDialog) { + LegendInfoDialog( + pairedRes = listOf( + Pair(R.string.snr, R.string.snr_definition), + Pair(R.string.rssi, R.string.rssi_definition) + ), + onDismiss = { displayInfoDialog = false } + ) + } + + SignalMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + meshPackets = state.signalMetrics.reversed(), + promptInfoDialog = { displayInfoDialog = true } + ) + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(state.signalMetrics) { meshPacket -> SignalMetricsCard(meshPacket) } + } + } +} + +@Composable +private fun SignalMetricsChart( + modifier: Modifier = Modifier, + meshPackets: List, + promptInfoDialog: () -> Unit +) { + + ChartHeader(amount = meshPackets.size) + if (meshPackets.isEmpty()) { + return + } + + TimeLabels( + oldest = meshPackets.first().rxTime * MS_PER_SEC, + newest = meshPackets.last().rxTime * MS_PER_SEC + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val snrDiff = Metric.SNR.difference() + val rssiDiff = Metric.RSSI.difference() + + Box(contentAlignment = Alignment.TopStart) { + + ChartOverlay( + modifier = modifier, + lineColors = List(size = 5) { graphColor }, + labelColor = METRICS_COLORS[Metric.SNR.ordinal], + minValue = Metric.SNR.min, + maxValue = Metric.SNR.max, + leaveSpace = true + ) + LeftYLabels(modifier = modifier, labelColor = METRICS_COLORS[Metric.RSSI.ordinal]) + + /* Plot SNR and RSSI */ + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + val spacing = LEFT_LABEL_SPACING.dp.toPx() + val spacePerEntry = (width - spacing) / meshPackets.size + + /* Plot */ + val dataPointRadius = 2.dp.toPx() + for ((i, packet) in meshPackets.withIndex()) { + + val x = spacing + i * spacePerEntry + + /* SNR */ + val snrRatio = (packet.rxSnr - Metric.SNR.min) / snrDiff + val ySNR = height - (snrRatio * height) + drawCircle( + color = METRICS_COLORS[Metric.SNR.ordinal], + radius = dataPointRadius, + center = Offset(x, ySNR) + ) + + /* RSSI */ + val rssiRatio = (packet.rxRssi - Metric.RSSI.min) / rssiDiff + val yRssi = height - (rssiRatio * height) + drawCircle( + color = METRICS_COLORS[Metric.RSSI.ordinal], + radius = dataPointRadius, + center = Offset(x, yRssi) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Legend(legendData = LEGEND_DATA, promptInfoDialog) + + Spacer(modifier = Modifier.height(16.dp)) +} + +/** + * Draws a set of Y labels on the left side of the graph. + * Currently only used for the RSSI labels. + */ +@Composable +private fun LeftYLabels( + modifier: Modifier, + labelColor: Color, +) { + val range = Metric.RSSI.difference() + val verticalSpacing = range / LINE_LIMIT + val density = LocalDensity.current + Canvas(modifier = modifier) { + + val height = size.height + + /* Y Labels */ + + val textPaint = Paint().apply { + color = labelColor.toArgb() + textAlign = Paint.Align.LEFT + textSize = density.run { 12.dp.toPx() } + typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) + alpha = TEXT_PAINT_ALPHA + } + drawContext.canvas.nativeCanvas.apply { + var label = Metric.RSSI.min + for (i in 0..LINE_LIMIT) { + val ratio = (label - Metric.RSSI.min) / range + val y = height - (ratio * height) + drawText( + "${label.toInt()}", + 4.dp.toPx(), + y + 4.dp.toPx(), + textPaint + ) + label += verticalSpacing + } + } + } +} + +@Composable +private fun SignalMetricsCard(meshPacket: MeshPacket) { + val time = meshPacket.rxTime * MS_PER_SEC + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface { + SelectionContainer { + Row( + modifier = Modifier.fillMaxWidth() + ) { + + /* Data */ + Box( + modifier = Modifier + .weight(weight = 5f) + .height(IntrinsicSize.Min) + ) { + Column( + modifier = Modifier + .padding(8.dp) + ) { + /* Time */ + Row( + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + /* SNR and RSSI */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Snr(meshPacket.rxSnr) + Rssi(meshPacket.rxRssi) + } + } + } + + /* Signal Indicator */ + Box( + modifier = Modifier + .weight(weight = 3f) + .height(IntrinsicSize.Max) + ) { + LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi) + } + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SimpleAlertDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SimpleAlertDialog.kt new file mode 100644 index 000000000..0c8f53d58 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SimpleAlertDialog.kt @@ -0,0 +1,77 @@ +package com.geeksville.mesh.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.theme.AppTheme + +@Composable +fun SimpleAlertDialog( + @StringRes title: Int, + text: @Composable (() -> Unit)? = null, + onDismiss: () -> Unit = {}, +) = AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier + .padding(horizontal = 16.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colors.onSurface, + ), + ) { Text(text = stringResource(id = R.string.close)) } + }, + title = { + Text( + text = stringResource(id = title), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + }, + text = text, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, +) + +@Composable +fun SimpleAlertDialog( + @StringRes title: Int, + @StringRes text: Int, + onDismiss: () -> Unit = {}, +) = SimpleAlertDialog( + onDismiss = onDismiss, + title = title, + text = { + Text( + text = stringResource(id = text), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + }, +) + +@PreviewLightDark +@Composable +private fun SimpleAlertDialogPreview() { + AppTheme { + SimpleAlertDialog( + title = R.string.message, + text = R.string.sample_message, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SwitchPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SwitchPreference.kt new file mode 100644 index 000000000..d5b99deec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SwitchPreference.kt @@ -0,0 +1,66 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +@Composable +fun SwitchPreference( + title: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = 16.dp), +) { + Row( + modifier = modifier + .fillMaxWidth() + .size(48.dp) + .padding(padding), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.body1, + color = if (enabled) { + Color.Unspecified + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + }, + ) + Switch( + modifier = modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + enabled = enabled, + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + uncheckedThumbColor = colorResource(R.color.colourGrey) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SwitchPreferencePreview() { + SwitchPreference(title = "Setting", checked = true, enabled = true, onCheckedChange = {}) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/TextDividerPreference.kt b/app/src/main/java/com/geeksville/mesh/ui/components/TextDividerPreference.kt new file mode 100644 index 000000000..64f5db65a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/TextDividerPreference.kt @@ -0,0 +1,73 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun TextDividerPreference( + title: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingIcon: ImageVector? = null, +) { + TextDividerPreference( + title = AnnotatedString(text = title), + enabled = enabled, + modifier = modifier, + trailingIcon = trailingIcon, + ) +} + +@Composable +fun TextDividerPreference( + title: AnnotatedString, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingIcon: ImageVector? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + backgroundColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, + ) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.body1, + color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified, + ) + if (trailingIcon != null) Icon( + trailingIcon, "trailingIcon", + modifier = modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TextDividerPreferencePreview() { + TextDividerPreference(title = "Advanced settings") +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/TimeTickWithLifecycle.kt b/app/src/main/java/com/geeksville/mesh/ui/components/TimeTickWithLifecycle.kt new file mode 100644 index 000000000..3d15b524d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/TimeTickWithLifecycle.kt @@ -0,0 +1,56 @@ +package com.geeksville.mesh.ui.components + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LifecycleResumeEffect + +@Composable +fun rememberTimeTickWithLifecycle(): Long { + val context = LocalContext.current + var value by remember { mutableLongStateOf(System.currentTimeMillis()) } + val receiver = TimeBroadcastReceiver { value = System.currentTimeMillis() } + + LifecycleResumeEffect(Unit) { + receiver.register(context) + value = System.currentTimeMillis() + + onPauseOrDispose { + receiver.unregister(context) + } + } + + return value +} + +private class TimeBroadcastReceiver( + val onTimeChanged: () -> Unit, +) : BroadcastReceiver() { + private var registered = false + + override fun onReceive(context: Context, intent: Intent) { + onTimeChanged() + } + + fun register(context: Context) { + if (!registered) { + val filter = IntentFilter(Intent.ACTION_TIME_TICK) + context.registerReceiver(this, filter) + registered = true + } + } + + fun unregister(context: Context) { + if (registered) { + context.unregisterReceiver(this) + registered = false + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt new file mode 100644 index 000000000..d5c2bd05d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/TracerouteLog.kt @@ -0,0 +1,197 @@ +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.fullRouteDiscovery +import com.geeksville.mesh.model.getTracerouteResponse +import com.geeksville.mesh.ui.theme.AppTheme +import java.text.DateFormat + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TracerouteLogScreen( + viewModel: MetricsViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val dateFormat = remember { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + } + + fun getUsername(nodeNum: Int): String = + with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } + + var showDialog by remember { mutableStateOf(null) } + + if (showDialog != null) { + val message = showDialog ?: return + SimpleAlertDialog( + title = R.string.traceroute, + text = { + SelectionContainer { + Text(text = message) + } + }, + onDismiss = { showDialog = null } + ) + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(state.tracerouteRequests, key = { it.uuid }) { log -> + val result = remember(state.tracerouteRequests) { + state.tracerouteResults.find { it.decoded.requestId == log.fromRadio.packet.id } + } + val route = remember(result) { result?.fullRouteDiscovery } + + val time = dateFormat.format(log.received_date) + val (text, icon) = route.getTextAndIcon() + var expanded by remember { mutableStateOf(false) } + + Box { + TracerouteItem( + icon = icon, + text = "$time - $text", + modifier = Modifier.combinedClickable( + onLongClick = { expanded = true }, + ) { + if (result != null) { + showDialog = result.getTracerouteResponse(::getUsername) + } + } + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DeleteItem { + viewModel.deleteLog(log.uuid) + expanded = false + } + } + } + } + } +} + +@Composable +private fun DeleteItem(onClick: () -> Unit) { + DropdownMenuItem(onClick = onClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(id = R.string.delete), + tint = MaterialTheme.colors.error, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = R.string.delete), + color = MaterialTheme.colors.error, + ) + } +} + +@Composable +private fun TracerouteItem( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(vertical = 2.dp), + elevation = 4.dp + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(id = R.string.traceroute) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.body1, + ) + } + } +} + +@Composable +private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair = when { + this == null -> { + stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff + } + + routeCount <= 2 -> { + stringResource(R.string.traceroute_direct) to Icons.Default.Group + } + + routeCount == routeBackCount -> { + val hops = routeCount - 2 + pluralStringResource(R.plurals.traceroute_hops, hops, hops) to Icons.Default.Groups + } + + else -> { + val (towards, back) = maxOf(0, routeCount - 2) to maxOf(0, routeBackCount - 2) + stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups + } +} + +@PreviewLightDark +@Composable +private fun TracerouteItemPreview() { + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + AppTheme { + TracerouteItem( + icon = Icons.Default.Group, + text = "${dateFormat.format(System.currentTimeMillis())} - Direct" + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/AmbientLightingConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/AmbientLightingConfigItemList.kt new file mode 100644 index 000000000..a787cf90b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/AmbientLightingConfigItemList.kt @@ -0,0 +1,131 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun AmbientLightingConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + AmbientLightingConfigItemList( + ambientLightingConfig = state.moduleConfig.ambientLighting, + enabled = state.connected, + onSaveClicked = { ambientLightingInput -> + val config = moduleConfig { ambientLighting = ambientLightingInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun AmbientLightingConfigItemList( + ambientLightingConfig: ModuleConfigProtos.ModuleConfig.AmbientLightingConfig, + enabled: Boolean, + onSaveClicked: (ModuleConfigProtos.ModuleConfig.AmbientLightingConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var ambientLightingInput by rememberSaveable { mutableStateOf(ambientLightingConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Ambient Lighting Config") } + + item { + SwitchPreference(title = "LED state", + checked = ambientLightingInput.ledState, + enabled = enabled, + onCheckedChange = { + ambientLightingInput = ambientLightingInput.copy { ledState = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Current", + value = ambientLightingInput.current, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + ambientLightingInput = ambientLightingInput.copy { current = it } + }) + } + + item { + EditTextPreference(title = "Red", + value = ambientLightingInput.red, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { ambientLightingInput = ambientLightingInput.copy { red = it } }) + } + + item { + EditTextPreference(title = "Green", + value = ambientLightingInput.green, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { ambientLightingInput = ambientLightingInput.copy { green = it } }) + } + + item { + EditTextPreference(title = "Blue", + value = ambientLightingInput.blue, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { ambientLightingInput = ambientLightingInput.copy { blue = it } }) + } + + item { + PreferenceFooter( + enabled = ambientLightingInput != ambientLightingConfig, + onCancelClicked = { + focusManager.clearFocus() + ambientLightingInput = ambientLightingConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(ambientLightingInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AmbientLightingConfigPreview() { + AmbientLightingConfigItemList( + ambientLightingConfig = ModuleConfigProtos.ModuleConfig.AmbientLightingConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/AudioConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/AudioConfigItemList.kt new file mode 100644 index 000000000..cd3667bb4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/AudioConfigItemList.kt @@ -0,0 +1,147 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.AudioConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun AudioConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + AudioConfigItemList( + audioConfig = state.moduleConfig.audio, + enabled = state.connected, + onSaveClicked = { audioInput -> + val config = moduleConfig { audio = audioInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun AudioConfigItemList( + audioConfig: AudioConfig, + enabled: Boolean, + onSaveClicked: (AudioConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var audioInput by rememberSaveable { mutableStateOf(audioConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Audio Config") } + + item { + SwitchPreference(title = "CODEC 2 enabled", + checked = audioInput.codec2Enabled, + enabled = enabled, + onCheckedChange = { audioInput = audioInput.copy { codec2Enabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "PTT pin", + value = audioInput.pttPin, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { audioInput = audioInput.copy { pttPin = it } }) + } + + item { + DropDownPreference(title = "CODEC2 sample rate", + enabled = enabled, + items = AudioConfig.Audio_Baud.entries + .filter { it != AudioConfig.Audio_Baud.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = audioInput.bitrate, + onItemSelected = { audioInput = audioInput.copy { bitrate = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "I2S word select", + value = audioInput.i2SWs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { audioInput = audioInput.copy { i2SWs = it } }) + } + + item { + EditTextPreference(title = "I2S data in", + value = audioInput.i2SSd, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { audioInput = audioInput.copy { i2SSd = it } }) + } + + item { + EditTextPreference(title = "I2S data out", + value = audioInput.i2SDin, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { audioInput = audioInput.copy { i2SDin = it } }) + } + + item { + EditTextPreference(title = "I2S clock", + value = audioInput.i2SSck, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { audioInput = audioInput.copy { i2SSck = it } }) + } + + item { + PreferenceFooter( + enabled = audioInput != audioConfig, + onCancelClicked = { + focusManager.clearFocus() + audioInput = audioConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(audioInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AudioConfigPreview() { + AudioConfigItemList( + audioConfig = AudioConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/BluetoothConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/BluetoothConfigItemList.kt new file mode 100644 index 000000000..9c38cefcd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/BluetoothConfigItemList.kt @@ -0,0 +1,119 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.BluetoothConfig +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun BluetoothConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + BluetoothConfigItemList( + bluetoothConfig = state.radioConfig.bluetooth, + enabled = state.connected, + onSaveClicked = { bluetoothInput -> + val config = config { bluetooth = bluetoothInput } + viewModel.setConfig(config) + } + ) +} + +@Composable +fun BluetoothConfigItemList( + bluetoothConfig: BluetoothConfig, + enabled: Boolean, + onSaveClicked: (BluetoothConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var bluetoothInput by rememberSaveable { mutableStateOf(bluetoothConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Bluetooth Config") } + + item { + SwitchPreference(title = "Bluetooth enabled", + checked = bluetoothInput.enabled, + enabled = enabled, + onCheckedChange = { bluetoothInput = bluetoothInput.copy { this.enabled = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "Pairing mode", + enabled = enabled, + items = BluetoothConfig.PairingMode.entries + .filter { it != BluetoothConfig.PairingMode.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = bluetoothInput.mode, + onItemSelected = { bluetoothInput = bluetoothInput.copy { mode = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Fixed PIN", + value = bluetoothInput.fixedPin, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it.toString().length == 6) { // ensure 6 digits + bluetoothInput = bluetoothInput.copy { fixedPin = it } + } + }) + } + + item { + PreferenceFooter( + enabled = bluetoothInput != bluetoothConfig, + onCancelClicked = { + focusManager.clearFocus() + bluetoothInput = bluetoothConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(bluetoothInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun BluetoothConfigPreview() { + BluetoothConfigItemList( + bluetoothConfig = BluetoothConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt new file mode 100644 index 000000000..f962c5e98 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt @@ -0,0 +1,239 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.CannedMessageConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun CannedMessageConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + CannedMessageConfigItemList( + messages = state.cannedMessageMessages, + cannedMessageConfig = state.moduleConfig.cannedMessage, + enabled = state.connected, + onSaveClicked = { messagesInput, cannedMessageInput -> + if (messagesInput != state.cannedMessageMessages) { + viewModel.setCannedMessages(messagesInput) + } + if (cannedMessageInput != state.moduleConfig.cannedMessage) { + val config = moduleConfig { cannedMessage = cannedMessageInput } + viewModel.setModuleConfig(config) + } + } + ) +} + +@Composable +fun CannedMessageConfigItemList( + messages: String, + cannedMessageConfig: CannedMessageConfig, + enabled: Boolean, + onSaveClicked: (messages: String, config: CannedMessageConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var messagesInput by rememberSaveable { mutableStateOf(messages) } + var cannedMessageInput by rememberSaveable { mutableStateOf(cannedMessageConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Canned Message Config") } + + item { + SwitchPreference(title = "Canned message enabled", + checked = cannedMessageInput.enabled, + enabled = enabled, + onCheckedChange = { + cannedMessageInput = cannedMessageInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Rotary encoder #1 enabled", + checked = cannedMessageInput.rotary1Enabled, + enabled = enabled, + onCheckedChange = { + cannedMessageInput = cannedMessageInput.copy { rotary1Enabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "GPIO pin for rotary encoder A port", + value = cannedMessageInput.inputbrokerPinA, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerPinA = it } + }) + } + + item { + EditTextPreference(title = "GPIO pin for rotary encoder B port", + value = cannedMessageInput.inputbrokerPinB, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerPinB = it } + }) + } + + item { + EditTextPreference(title = "GPIO pin for rotary encoder Press port", + value = cannedMessageInput.inputbrokerPinPress, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerPinPress = it } + }) + } + + item { + DropDownPreference(title = "Generate input event on Press", + enabled = enabled, + items = CannedMessageConfig.InputEventChar.entries + .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = cannedMessageInput.inputbrokerEventPress, + onItemSelected = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerEventPress = it } + }) + } + item { Divider() } + + item { + DropDownPreference(title = "Generate input event on CW", + enabled = enabled, + items = CannedMessageConfig.InputEventChar.entries + .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = cannedMessageInput.inputbrokerEventCw, + onItemSelected = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCw = it } + }) + } + item { Divider() } + + item { + DropDownPreference(title = "Generate input event on CCW", + enabled = enabled, + items = CannedMessageConfig.InputEventChar.entries + .filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = cannedMessageInput.inputbrokerEventCcw, + onItemSelected = { + cannedMessageInput = cannedMessageInput.copy { inputbrokerEventCcw = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Up/Down/Select input enabled", + checked = cannedMessageInput.updown1Enabled, + enabled = enabled, + onCheckedChange = { + cannedMessageInput = cannedMessageInput.copy { updown1Enabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Allow input source", + value = cannedMessageInput.allowInputSource, + maxSize = 63, // allow_input_source max_size:16 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + cannedMessageInput = cannedMessageInput.copy { allowInputSource = it } + }) + } + + item { + SwitchPreference(title = "Send bell", + checked = cannedMessageInput.sendBell, + enabled = enabled, + onCheckedChange = { + cannedMessageInput = cannedMessageInput.copy { sendBell = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Messages", + value = messagesInput, + maxSize = 200, // messages max_size:201 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { messagesInput = it } + ) + } + + item { + PreferenceFooter( + enabled = cannedMessageInput != cannedMessageConfig || messagesInput != messages, + onCancelClicked = { + focusManager.clearFocus() + messagesInput = messages + cannedMessageInput = cannedMessageConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(messagesInput, cannedMessageInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CannedMessageConfigPreview() { + CannedMessageConfigItemList( + messages = "", + cannedMessageConfig = CannedMessageConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { _, _ -> }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt new file mode 100644 index 000000000..c130d8558 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt @@ -0,0 +1,314 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Card +import androidx.compose.material.Checkbox +import androidx.compose.material.Chip +import androidx.compose.material.ContentAlpha +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Add +import androidx.compose.material.icons.twotone.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.R +import com.geeksville.mesh.channelSettings +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.dragContainer +import com.geeksville.mesh.ui.components.dragDropItemsIndexed +import com.geeksville.mesh.ui.components.rememberDragDropState + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ChannelItem( + index: Int, + title: String, + enabled: Boolean, + onClick: () -> Unit = {}, + elevation: Dp = 4.dp, + content: @Composable RowScope.() -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable(enabled = enabled) { onClick() }, + elevation = elevation, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp) + ) { + val textColor = if (enabled) { + Color.Unspecified + } else { + MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + } + + Chip(onClick = onClick) { + Text( + text = "$index", + color = textColor, + ) + } + Text( + text = title, + modifier = Modifier.weight(1f), + color = textColor, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.body1, + ) + content() + } + } +} + +@Composable +fun ChannelCard( + index: Int, + title: String, + enabled: Boolean, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + elevation: Dp = 4.dp, +) = ChannelItem( + index = index, + title = title, + enabled = enabled, + onClick = onEditClick, + elevation = elevation, +) { + IconButton(onClick = { onDeleteClick() }) { + Icon( + imageVector = Icons.TwoTone.Close, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.wrapContentSize(), + ) + } +} + +@Composable +fun ChannelSelection( + index: Int, + title: String, + enabled: Boolean, + isSelected: Boolean, + onSelected: (Boolean) -> Unit +) = ChannelItem( + index = index, + title = title, + enabled = enabled, + onClick = {}, +) { + Checkbox( + enabled = enabled, + checked = isSelected, + onCheckedChange = onSelected, + ) +} + +@Composable +fun ChannelConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + ChannelSettingsItemList( + settingsList = state.channelList, + modemPresetName = Channel(loraConfig = state.radioConfig.lora).name, + enabled = state.connected, + maxChannels = viewModel.maxChannels, + onPositiveClicked = { channelListInput -> + viewModel.updateChannels(channelListInput, state.channelList) + }, + ) +} + +@Composable +fun ChannelSettingsItemList( + settingsList: List, + modemPresetName: String = "Default", + maxChannels: Int = 8, + enabled: Boolean, + onNegativeClicked: () -> Unit = { }, + onPositiveClicked: (List) -> Unit, +) { + val focusManager = LocalFocusManager.current + val settingsListInput = rememberSaveable( + saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() }) + ) { settingsList.toMutableStateList() } + + val listState = rememberLazyListState() + val dragDropState = rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex -> + if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) { + settingsListInput.apply { add(toIndex, removeAt(fromIndex)) } + } + } + + val isEditing: Boolean = settingsList.size != settingsListInput.size || + settingsList.zip(settingsListInput).any { (item1, item2) -> item1 != item2 } + + var showEditChannelDialog: Int? by rememberSaveable { mutableStateOf(null) } + + if (showEditChannelDialog != null) { + val index = showEditChannelDialog ?: return + EditChannelDialog( + channelSettings = with(settingsListInput) { + if (size > index) get(index) else channelSettings { } + }, + modemPresetName = modemPresetName, + onAddClick = { + if (settingsListInput.size > index) { + settingsListInput[index] = it + } else { + settingsListInput.add(it) + } + showEditChannelDialog = null + }, + onDismissRequest = { showEditChannelDialog = null } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = { }, enabled = false) + ) { + LazyColumn( + modifier = Modifier.dragContainer( + dragDropState = dragDropState, + haptics = LocalHapticFeedback.current, + ), + state = listState, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { PreferenceCategory(text = "Channels") } + + dragDropItemsIndexed( + items = settingsListInput, + dragDropState = dragDropState, + ) { index, channel, isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 4.dp, label = "drag") + ChannelCard( + elevation = elevation, + index = index, + title = channel.name.ifEmpty { modemPresetName }, + enabled = enabled, + onEditClick = { showEditChannelDialog = index }, + onDeleteClick = { settingsListInput.removeAt(index) } + ) + } + + item { + PreferenceFooter( + enabled = isEditing, + negativeText = R.string.cancel, + onNegativeClicked = { + focusManager.clearFocus() + settingsListInput.clear() + settingsListInput.addAll(settingsList) + onNegativeClicked() + }, + positiveText = R.string.send, + onPositiveClicked = { + focusManager.clearFocus() + onPositiveClicked(settingsListInput) + } + ) + } + } + + AnimatedVisibility( + visible = maxChannels > settingsListInput.size, + modifier = Modifier.align(Alignment.BottomEnd), + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing) + ), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing) + ) + ) { + FloatingActionButton( + onClick = { + if (maxChannels > settingsListInput.size) { + settingsListInput.add(channelSettings { + psk = Channel.default.settings.psk + }) + showEditChannelDialog = settingsListInput.lastIndex + } + }, + modifier = Modifier.padding(16.dp) + ) { Icon(Icons.TwoTone.Add, stringResource(R.string.add)) } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ChannelSettingsPreview() { + ChannelSettingsItemList( + settingsList = listOf( + channelSettings { + psk = Channel.default.settings.psk + name = Channel.default.name + }, + channelSettings { + name = stringResource(R.string.channel_name) + }, + ), + enabled = true, + onPositiveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/DetectionSensorConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/DetectionSensorConfigItemList.kt new file mode 100644 index 000000000..4cb887a13 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/DetectionSensorConfigItemList.kt @@ -0,0 +1,182 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun DetectionSensorConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + DetectionSensorConfigItemList( + detectionSensorConfig = state.moduleConfig.detectionSensor, + enabled = state.connected, + onSaveClicked = { detectionSensorInput -> + val config = moduleConfig { detectionSensor = detectionSensorInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Suppress("LongMethod") +@Composable +fun DetectionSensorConfigItemList( + detectionSensorConfig: ModuleConfig.DetectionSensorConfig, + enabled: Boolean, + onSaveClicked: (ModuleConfig.DetectionSensorConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var detectionSensorInput by rememberSaveable { mutableStateOf(detectionSensorConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Detection Sensor Config") } + + item { + SwitchPreference(title = "Detection Sensor enabled", + checked = detectionSensorInput.enabled, + enabled = enabled, + onCheckedChange = { + detectionSensorInput = detectionSensorInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Minimum broadcast (seconds)", + value = detectionSensorInput.minimumBroadcastSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + detectionSensorInput = detectionSensorInput.copy { minimumBroadcastSecs = it } + }) + } + + item { + EditTextPreference(title = "State broadcast (seconds)", + value = detectionSensorInput.stateBroadcastSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + detectionSensorInput = detectionSensorInput.copy { stateBroadcastSecs = it } + }) + } + + item { + SwitchPreference(title = "Send bell with alert message", + checked = detectionSensorInput.sendBell, + enabled = enabled, + onCheckedChange = { + detectionSensorInput = detectionSensorInput.copy { sendBell = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Friendly name", + value = detectionSensorInput.name, + maxSize = 19, // name max_size:20 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + detectionSensorInput = detectionSensorInput.copy { name = it } + }) + } + + item { + EditTextPreference(title = "GPIO pin to monitor", + value = detectionSensorInput.monitorPin, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + detectionSensorInput = detectionSensorInput.copy { monitorPin = it } + }) + } + + item { + DropDownPreference( + title = "Detection trigger type", + enabled = enabled, + items = ModuleConfig.DetectionSensorConfig.TriggerType.entries + .filter { it != ModuleConfig.DetectionSensorConfig.TriggerType.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = detectionSensorInput.detectionTriggerType, + onItemSelected = { + detectionSensorInput = detectionSensorInput.copy { detectionTriggerType = it } + } + ) + } + item { Divider() } + + item { + SwitchPreference(title = "Use INPUT_PULLUP mode", + checked = detectionSensorInput.usePullup, + enabled = enabled, + onCheckedChange = { + detectionSensorInput = detectionSensorInput.copy { usePullup = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = detectionSensorInput != detectionSensorConfig, + onCancelClicked = { + focusManager.clearFocus() + detectionSensorInput = detectionSensorConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(detectionSensorInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DetectionSensorConfigPreview() { + DetectionSensorConfigItemList( + detectionSensorConfig = ModuleConfig.DetectionSensorConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/DeviceConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/DeviceConfigItemList.kt new file mode 100644 index 000000000..94c36bc52 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/DeviceConfigItemList.kt @@ -0,0 +1,218 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig +import com.geeksville.mesh.R +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +private val DeviceConfig.Role.stringRes: Int + get() = when (this) { + DeviceConfig.Role.CLIENT -> R.string.role_client + DeviceConfig.Role.CLIENT_MUTE -> R.string.role_client_mute + DeviceConfig.Role.ROUTER -> R.string.role_router + DeviceConfig.Role.ROUTER_CLIENT -> R.string.role_router_client + DeviceConfig.Role.REPEATER -> R.string.role_repeater + DeviceConfig.Role.TRACKER -> R.string.role_tracker + DeviceConfig.Role.SENSOR -> R.string.role_sensor + DeviceConfig.Role.TAK -> R.string.role_tak + DeviceConfig.Role.CLIENT_HIDDEN -> R.string.role_client_hidden + DeviceConfig.Role.LOST_AND_FOUND -> R.string.role_lost_and_found + DeviceConfig.Role.TAK_TRACKER -> R.string.role_tak_tracker + else -> R.string.unrecognized + } + +private val DeviceConfig.RebroadcastMode.stringRes: Int + get() = when (this) { + DeviceConfig.RebroadcastMode.ALL -> R.string.rebroadcast_mode_all + DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> R.string.rebroadcast_mode_all_skip_decoding + DeviceConfig.RebroadcastMode.LOCAL_ONLY -> R.string.rebroadcast_mode_local_only + DeviceConfig.RebroadcastMode.KNOWN_ONLY -> R.string.rebroadcast_mode_known_only + DeviceConfig.RebroadcastMode.NONE -> R.string.rebroadcast_mode_none + DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> R.string.rebroadcast_mode_core_portnums_only + else -> R.string.unrecognized + } + +@Composable +fun DeviceConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + DeviceConfigItemList( + deviceConfig = state.radioConfig.device, + enabled = state.connected, + onSaveClicked = { deviceInput -> + val config = config { device = deviceInput } + viewModel.setConfig(config) + } + ) +} + +@Composable +fun DeviceConfigItemList( + deviceConfig: DeviceConfig, + enabled: Boolean, + onSaveClicked: (DeviceConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var deviceInput by rememberSaveable { mutableStateOf(deviceConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Device Config") } + + item { + DropDownPreference( + title = "Role", + enabled = enabled, + selectedItem = deviceInput.role, + onItemSelected = { deviceInput = deviceInput.copy { role = it } }, + summary = stringResource(id = deviceInput.role.stringRes), + ) + } + item { Divider() } + + item { + EditTextPreference(title = "Redefine PIN_BUTTON", + value = deviceInput.buttonGpio, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + deviceInput = deviceInput.copy { buttonGpio = it } + }) + } + + item { + EditTextPreference(title = "Redefine PIN_BUZZER", + value = deviceInput.buzzerGpio, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + deviceInput = deviceInput.copy { buzzerGpio = it } + }) + } + + item { + DropDownPreference( + title = "Rebroadcast mode", + enabled = enabled, + selectedItem = deviceInput.rebroadcastMode, + onItemSelected = { deviceInput = deviceInput.copy { rebroadcastMode = it } }, + summary = stringResource(id = deviceInput.rebroadcastMode.stringRes), + ) + } + item { Divider() } + + item { + EditTextPreference(title = "NodeInfo broadcast interval (seconds)", + value = deviceInput.nodeInfoBroadcastSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + deviceInput = deviceInput.copy { nodeInfoBroadcastSecs = it } + }) + } + + item { + SwitchPreference(title = "Double tap as button press", + checked = deviceInput.doubleTapAsButtonPress, + enabled = enabled, + onCheckedChange = { + deviceInput = deviceInput.copy { doubleTapAsButtonPress = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Disable triple-click", + checked = deviceInput.disableTripleClick, + enabled = enabled, + onCheckedChange = { + deviceInput = deviceInput.copy { disableTripleClick = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "POSIX Timezone", + value = deviceInput.tzdef, + maxSize = 64, // tzdef max_size:65 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + deviceInput = deviceInput.copy { tzdef = it } + }, + ) + } + + item { + SwitchPreference(title = "Disable LED heartbeat", + checked = deviceInput.ledHeartbeatDisabled, + enabled = enabled, + onCheckedChange = { + deviceInput = deviceInput.copy { ledHeartbeatDisabled = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = deviceInput != deviceConfig, + onCancelClicked = { + focusManager.clearFocus() + deviceInput = deviceConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(deviceInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DeviceConfigPreview() { + DeviceConfigItemList( + deviceConfig = DeviceConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/DisplayConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/DisplayConfigItemList.kt new file mode 100644 index 000000000..4d53018d5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/DisplayConfigItemList.kt @@ -0,0 +1,193 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun DisplayConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + DisplayConfigItemList( + displayConfig = state.radioConfig.display, + enabled = state.connected, + onSaveClicked = { displayInput -> + val config = config { display = displayInput } + viewModel.setConfig(config) + } + ) +} + +@Composable +fun DisplayConfigItemList( + displayConfig: DisplayConfig, + enabled: Boolean, + onSaveClicked: (DisplayConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var displayInput by rememberSaveable { mutableStateOf(displayConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Display Config") } + + item { + EditTextPreference(title = "Screen timeout (seconds)", + value = displayInput.screenOnSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { displayInput = displayInput.copy { screenOnSecs = it } }) + } + + item { + DropDownPreference(title = "GPS coordinates format", + enabled = enabled, + items = DisplayConfig.GpsCoordinateFormat.entries + .filter { it != DisplayConfig.GpsCoordinateFormat.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = displayInput.gpsFormat, + onItemSelected = { displayInput = displayInput.copy { gpsFormat = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Auto screen carousel (seconds)", + value = displayInput.autoScreenCarouselSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + displayInput = displayInput.copy { autoScreenCarouselSecs = it } + }) + } + + item { + SwitchPreference(title = "Compass north top", + checked = displayInput.compassNorthTop, + enabled = enabled, + onCheckedChange = { displayInput = displayInput.copy { compassNorthTop = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Flip screen", + checked = displayInput.flipScreen, + enabled = enabled, + onCheckedChange = { displayInput = displayInput.copy { flipScreen = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "Display units", + enabled = enabled, + items = DisplayConfig.DisplayUnits.entries + .filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = displayInput.units, + onItemSelected = { displayInput = displayInput.copy { units = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "Override OLED auto-detect", + enabled = enabled, + items = DisplayConfig.OledType.entries + .filter { it != DisplayConfig.OledType.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = displayInput.oled, + onItemSelected = { displayInput = displayInput.copy { oled = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "Display mode", + enabled = enabled, + items = DisplayConfig.DisplayMode.entries + .filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = displayInput.displaymode, + onItemSelected = { displayInput = displayInput.copy { displaymode = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Heading bold", + checked = displayInput.headingBold, + enabled = enabled, + onCheckedChange = { displayInput = displayInput.copy { headingBold = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Wake screen on tap or motion", + checked = displayInput.wakeOnTapOrMotion, + enabled = enabled, + onCheckedChange = { displayInput = displayInput.copy { wakeOnTapOrMotion = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "Compass orientation", + enabled = enabled, + items = DisplayConfig.CompassOrientation.entries + .filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = displayInput.compassOrientation, + onItemSelected = { displayInput = displayInput.copy { compassOrientation = it } }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = displayInput != displayConfig, + onCancelClicked = { + focusManager.clearFocus() + displayInput = displayConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(displayInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DisplayConfigPreview() { + DisplayConfigItemList( + displayConfig = DisplayConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt new file mode 100644 index 000000000..80f4cfca9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt @@ -0,0 +1,161 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.R +import com.geeksville.mesh.channelSettings +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.ui.components.EditBase64Preference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PositionPrecisionPreference +import com.geeksville.mesh.ui.components.SwitchPreference + +@Suppress("LongMethod") +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun EditChannelDialog( + channelSettings: ChannelProtos.ChannelSettings, + onAddClick: (ChannelProtos.ChannelSettings) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + modemPresetName: String = "Default", +) { + val focusManager = LocalFocusManager.current + var isFocused by remember { mutableStateOf(false) } + + var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) } + + AlertDialog( + onDismissRequest = onDismissRequest, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + text = { + Column(modifier.fillMaxWidth()) { + EditTextPreference( + title = stringResource(R.string.channel_name), + value = if (isFocused) channelInput.name else channelInput.name.ifEmpty { modemPresetName }, + maxSize = 11, // name max_size:12 + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + channelInput = channelInput.copy { + name = it.trim() + if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey() + } + }, + onFocusChanged = { isFocused = it.isFocused }, + ) + + EditBase64Preference( + title = "PSK", + value = channelInput.psk, + enabled = true, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = { + val fullPsk = Channel(channelSettings { psk = it }).psk + if (fullPsk.size() in setOf(0, 16, 32)) { + channelInput = channelInput.copy { psk = it } + } + }, + onGenerateKey = { + channelInput = channelInput.copy { psk = Channel.getRandomKey() } + }, + ) + + SwitchPreference( + title = "Uplink enabled", + checked = channelInput.uplinkEnabled, + enabled = true, + onCheckedChange = { + channelInput = channelInput.copy { uplinkEnabled = it } + }, + padding = PaddingValues(0.dp) + ) + + SwitchPreference( + title = "Downlink enabled", + checked = channelInput.downlinkEnabled, + enabled = true, + onCheckedChange = { + channelInput = channelInput.copy { downlinkEnabled = it } + }, + padding = PaddingValues(0.dp) + ) + + PositionPrecisionPreference( + title = "Position enabled", + enabled = true, + value = channelInput.moduleSettings.positionPrecision, + onValueChanged = { + val module = channelInput.moduleSettings.copy { positionPrecision = it } + channelInput = channelInput.copy { moduleSettings = module } + }, + ) + } + }, + buttons = { + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + modifier = modifier.weight(1f), + onClick = onDismissRequest + ) { Text(stringResource(R.string.cancel)) } + Button( + modifier = modifier.weight(1f), + onClick = { + onAddClick(channelInput) + }, + enabled = true, + ) { Text(stringResource(R.string.save)) } + } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun EditChannelDialogPreview() { + EditChannelDialog( + channelSettings = channelSettings { + psk = Channel.default.settings.psk + name = Channel.default.name + }, + onAddClick = { }, + onDismissRequest = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt new file mode 100644 index 000000000..1994b1074 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditDeviceProfileDialog.kt @@ -0,0 +1,117 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.components.SwitchPreference +import com.google.protobuf.Descriptors + +private const val SupportedFields = 7 + +@Suppress("LongMethod") +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun EditDeviceProfileDialog( + title: String, + deviceProfile: DeviceProfile, + onConfirm: (DeviceProfile) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val state = remember { + val fields = deviceProfile.descriptorForType.fields + .filter { it.number < SupportedFields } // TODO add ringtone & canned messages + mutableStateMapOf() + .apply { putAll(fields.associateWith(deviceProfile::hasField)) } + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + text = { + Column(modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.h6.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + Divider() + state.keys.sortedBy { it.number }.forEach { field -> + SwitchPreference( + title = field.name, + checked = state[field] == true, + enabled = deviceProfile.hasField(field), + onCheckedChange = { state[field] = it }, + padding = PaddingValues(0.dp) + ) + } + Divider() + } + }, + buttons = { + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + modifier = modifier.weight(1f), + onClick = onDismiss + ) { Text(stringResource(R.string.cancel)) } + Button( + modifier = modifier.weight(1f), + onClick = { + val builder = DeviceProfile.newBuilder() + deviceProfile.allFields.forEach { (field, value) -> + if (state[field] == true) { + builder.setField(field, value) + } + } + onConfirm(builder.build()) + }, + enabled = state.values.any { it }, + ) { Text(stringResource(R.string.save)) } + } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun EditDeviceProfileDialogPreview() { + EditDeviceProfileDialog( + title = "Export configuration", + deviceProfile = DeviceProfile.getDefaultInstance(), + onConfirm = {}, + onDismiss = {}, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt new file mode 100644 index 000000000..fdddcc864 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt @@ -0,0 +1,271 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.ExternalNotificationConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference +import com.geeksville.mesh.ui.components.TextDividerPreference + +@Composable +fun ExternalNotificationConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + ExternalNotificationConfigItemList( + ringtone = state.ringtone, + extNotificationConfig = state.moduleConfig.externalNotification, + enabled = state.connected, + onSaveClicked = { ringtoneInput, extNotificationInput -> + if (ringtoneInput != state.ringtone) { + viewModel.setRingtone(ringtoneInput) + } + if (extNotificationInput != state.moduleConfig.externalNotification) { + val config = moduleConfig { externalNotification = extNotificationInput } + viewModel.setModuleConfig(config) + } + } + ) +} + +@Composable +fun ExternalNotificationConfigItemList( + ringtone: String, + extNotificationConfig: ExternalNotificationConfig, + enabled: Boolean, + onSaveClicked: (ringtone: String, config: ExternalNotificationConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var ringtoneInput by rememberSaveable { mutableStateOf(ringtone) } + var externalNotificationInput by rememberSaveable { mutableStateOf(extNotificationConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "External Notification Config") } + + item { + SwitchPreference(title = "External notification enabled", + checked = externalNotificationInput.enabled, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { this.enabled = it } + }) + } + + item { TextDividerPreference("Notifications on message receipt", enabled = enabled) } + + item { + SwitchPreference(title = "Alert message LED", + checked = externalNotificationInput.alertMessage, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { alertMessage = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Alert message buzzer", + checked = externalNotificationInput.alertMessageBuzzer, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = + externalNotificationInput.copy { alertMessageBuzzer = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Alert message vibra", + checked = externalNotificationInput.alertMessageVibra, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = + externalNotificationInput.copy { alertMessageVibra = it } + }) + } + + item { TextDividerPreference("Notifications on alert/bell receipt", enabled = enabled) } + + item { + SwitchPreference(title = "Alert bell LED", + checked = externalNotificationInput.alertBell, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { alertBell = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Alert bell buzzer", + checked = externalNotificationInput.alertBellBuzzer, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = + externalNotificationInput.copy { alertBellBuzzer = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Alert bell vibra", + checked = externalNotificationInput.alertBellVibra, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = + externalNotificationInput.copy { alertBellVibra = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Output LED (GPIO)", + value = externalNotificationInput.output, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + externalNotificationInput = externalNotificationInput.copy { output = it } + }) + } + + if (externalNotificationInput.output != 0) item { + SwitchPreference(title = "Output LED active high", + checked = externalNotificationInput.active, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { active = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Output buzzer (GPIO)", + value = externalNotificationInput.outputBuzzer, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + externalNotificationInput = externalNotificationInput.copy { outputBuzzer = it } + }) + } + + if (externalNotificationInput.outputBuzzer != 0) item { + SwitchPreference(title = "Use PWM buzzer", + checked = externalNotificationInput.usePwm, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { usePwm = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Output vibra (GPIO)", + value = externalNotificationInput.outputVibra, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + externalNotificationInput = externalNotificationInput.copy { outputVibra = it } + }) + } + + item { + EditTextPreference(title = "Output duration (milliseconds)", + value = externalNotificationInput.outputMs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + externalNotificationInput = externalNotificationInput.copy { outputMs = it } + }) + } + + item { + EditTextPreference(title = "Nag timeout (seconds)", + value = externalNotificationInput.nagTimeout, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + externalNotificationInput = externalNotificationInput.copy { nagTimeout = it } + }) + } + + item { + EditTextPreference(title = "Ringtone", + value = ringtoneInput, + maxSize = 230, // ringtone max_size:231 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { ringtoneInput = it } + ) + } + + item { + SwitchPreference(title = "Use I2S as buzzer", + checked = externalNotificationInput.useI2SAsBuzzer, + enabled = enabled, + onCheckedChange = { + externalNotificationInput = externalNotificationInput.copy { useI2SAsBuzzer = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = externalNotificationInput != extNotificationConfig || ringtoneInput != ringtone, + onCancelClicked = { + focusManager.clearFocus() + ringtoneInput = ringtone + externalNotificationInput = extNotificationConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(ringtoneInput, externalNotificationInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExternalNotificationConfigPreview() { + ExternalNotificationConfigItemList( + ringtone = "", + extNotificationConfig = ExternalNotificationConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { _, _ -> }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/LoRaConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/LoRaConfigItemList.kt new file mode 100644 index 000000000..3b64c0252 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/LoRaConfigItemList.kt @@ -0,0 +1,270 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.model.numChannels +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditListPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun LoRaConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + LoRaConfigItemList( + loraConfig = state.radioConfig.lora, + primarySettings = state.channelList.getOrNull(0) ?: return, + enabled = state.connected, + onSaveClicked = { loraInput -> + val config = config { lora = loraInput } + viewModel.setConfig(config) + }, + hasPaFan = viewModel.hasPaFan, + ) +} + +@Suppress("LongMethod") +@Composable +fun LoRaConfigItemList( + loraConfig: LoRaConfig, + primarySettings: ChannelSettings, + enabled: Boolean, + onSaveClicked: (LoRaConfig) -> Unit, + hasPaFan: Boolean = false, +) { + val focusManager = LocalFocusManager.current + var loraInput by rememberSaveable { mutableStateOf(loraConfig) } + val primaryChannel by remember(loraInput) { + mutableStateOf(Channel(primarySettings, loraInput)) + } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "LoRa Config") } + + item { + SwitchPreference(title = "Use modem preset", + checked = loraInput.usePreset, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { usePreset = it } }) + } + item { Divider() } + + if (loraInput.usePreset) { + item { + DropDownPreference(title = "Modem preset", + enabled = enabled && loraInput.usePreset, + items = LoRaConfig.ModemPreset.entries + .filter { it != LoRaConfig.ModemPreset.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = loraInput.modemPreset, + onItemSelected = { loraInput = loraInput.copy { modemPreset = it } }) + } + item { Divider() } + } else { + item { + EditTextPreference(title = "Bandwidth", + value = loraInput.bandwidth, + enabled = enabled && !loraInput.usePreset, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { bandwidth = it } }) + } + + item { + EditTextPreference(title = "Spread factor", + value = loraInput.spreadFactor, + enabled = enabled && !loraInput.usePreset, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { spreadFactor = it } }) + } + + item { + EditTextPreference(title = "Coding rate", + value = loraInput.codingRate, + enabled = enabled && !loraInput.usePreset, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { codingRate = it } }) + } + } + + item { + EditTextPreference(title = "Frequency offset (MHz)", + value = loraInput.frequencyOffset, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { frequencyOffset = it } }) + } + + item { + DropDownPreference(title = "Region (frequency plan)", + enabled = enabled, + items = RegionInfo.entries.map { it.regionCode to it.description }, + selectedItem = loraInput.region, + onItemSelected = { loraInput = loraInput.copy { region = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Hop limit", + value = loraInput.hopLimit, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { hopLimit = it } }) + } + + item { + SwitchPreference(title = "TX enabled", + checked = loraInput.txEnabled, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { txEnabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "TX power (dBm)", + value = loraInput.txPower, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { loraInput = loraInput.copy { txPower = it } }) + } + + item { + var isFocused by remember { mutableStateOf(false) } + EditTextPreference(title = "Frequency slot", + value = if (isFocused || loraInput.channelNum != 0) loraInput.channelNum else primaryChannel.channelNum, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onFocusChanged = { isFocused = it.isFocused }, + onValueChanged = { + if (it <= loraInput.numChannels) { // total num of LoRa channels + loraInput = loraInput.copy { channelNum = it } + } + }) + } + + item { + SwitchPreference(title = "Override Duty Cycle", + checked = loraInput.overrideDutyCycle, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { overrideDutyCycle = it } }) + } + item { Divider() } + + item { + EditListPreference(title = "Ignore incoming", + list = loraInput.ignoreIncomingList, + maxCount = 3, // ignore_incoming max_count:3 + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValuesChanged = { list -> + loraInput = loraInput.copy { + ignoreIncoming.clear() + ignoreIncoming.addAll(list.filter { it != 0 }) + } + }) + } + + item { + SwitchPreference(title = "SX126X RX boosted gain", + checked = loraInput.sx126XRxBoostedGain, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { sx126XRxBoostedGain = it } }) + } + item { Divider() } + + item { + var isFocused by remember { mutableStateOf(false) } + EditTextPreference(title = "Override frequency (MHz)", + value = if (isFocused || loraInput.overrideFrequency != 0f) loraInput.overrideFrequency else primaryChannel.radioFreq, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onFocusChanged = { isFocused = it.isFocused }, + onValueChanged = { loraInput = loraInput.copy { overrideFrequency = it } }) + } + + if (hasPaFan) { + item { + SwitchPreference( + title = "PA fan disabled", + checked = loraInput.paFanDisabled, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { paFanDisabled = it } }) + } + item { Divider() } + } + + item { + SwitchPreference(title = "Ignore MQTT", + checked = loraInput.ignoreMqtt, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { ignoreMqtt = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "OK to MQTT", + checked = loraInput.configOkToMqtt, + enabled = enabled, + onCheckedChange = { loraInput = loraInput.copy { configOkToMqtt = it } }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = loraInput != loraConfig, + onCancelClicked = { + focusManager.clearFocus() + loraInput = loraConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(loraInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun LoRaConfigPreview() { + LoRaConfigItemList( + loraConfig = Channel.default.loraConfig, + primarySettings = Channel.default.settings, + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/MQTTConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/MQTTConfigItemList.kt new file mode 100644 index 000000000..d20903f9f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/MQTTConfigItemList.kt @@ -0,0 +1,211 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.MQTTConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditPasswordPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PositionPrecisionPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun MQTTConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + MQTTConfigItemList( + mqttConfig = state.moduleConfig.mqtt, + enabled = state.connected, + onSaveClicked = { mqttInput -> + val config = moduleConfig { mqtt = mqttInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun MQTTConfigItemList( + mqttConfig: MQTTConfig, + enabled: Boolean, + onSaveClicked: (MQTTConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var mqttInput by rememberSaveable { mutableStateOf(mqttConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "MQTT Config") } + + item { + SwitchPreference(title = "MQTT enabled", + checked = mqttInput.enabled, + enabled = enabled, + onCheckedChange = { mqttInput = mqttInput.copy { this.enabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Address", + value = mqttInput.address, + maxSize = 63, // address max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { mqttInput = mqttInput.copy { address = it } }) + } + + item { + EditTextPreference(title = "Username", + value = mqttInput.username, + maxSize = 63, // username max_size:64 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { mqttInput = mqttInput.copy { username = it } }) + } + + item { + EditPasswordPreference(title = "Password", + value = mqttInput.password, + maxSize = 63, // password max_size:64 + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { mqttInput = mqttInput.copy { password = it } }) + } + + item { + SwitchPreference(title = "Encryption enabled", + checked = mqttInput.encryptionEnabled, + enabled = enabled, + onCheckedChange = { mqttInput = mqttInput.copy { encryptionEnabled = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "JSON output enabled", + checked = mqttInput.jsonEnabled, + enabled = enabled, + onCheckedChange = { mqttInput = mqttInput.copy { jsonEnabled = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "TLS enabled", + checked = mqttInput.tlsEnabled, + enabled = enabled, + onCheckedChange = { mqttInput = mqttInput.copy { tlsEnabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Root topic", + value = mqttInput.root, + maxSize = 31, // root max_size:32 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { mqttInput = mqttInput.copy { root = it } }) + } + + item { + SwitchPreference(title = "Proxy to client enabled", + checked = mqttInput.proxyToClientEnabled, + enabled = enabled, + onCheckedChange = { mqttInput = mqttInput.copy { proxyToClientEnabled = it } }) + } + item { Divider() } + + item { + PositionPrecisionPreference( + title = "Map reporting", + enabled = enabled, + value = mqttInput.mapReportSettings.positionPrecision, + onValueChanged = { + val settings = mqttInput.mapReportSettings.copy { positionPrecision = it } + mqttInput = mqttInput.copy { + mapReportingEnabled = settings.positionPrecision > 0 + mapReportSettings = settings + } + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + item { Divider() } + + item { + EditTextPreference(title = "Map reporting interval (seconds)", + value = mqttInput.mapReportSettings.publishIntervalSecs, + enabled = enabled && mqttInput.mapReportingEnabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + val settings = mqttInput.mapReportSettings.copy { publishIntervalSecs = it } + mqttInput = mqttInput.copy { mapReportSettings = settings } + }, + ) + } + + item { + PreferenceFooter( + enabled = mqttInput != mqttConfig, + onCancelClicked = { + focusManager.clearFocus() + mqttInput = mqttConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(mqttInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MQTTConfigPreview() { + MQTTConfigItemList( + mqttConfig = MQTTConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/NeighborInfoConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/NeighborInfoConfigItemList.kt new file mode 100644 index 000000000..102672e0a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/NeighborInfoConfigItemList.kt @@ -0,0 +1,107 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun NeighborInfoConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + NeighborInfoConfigItemList( + neighborInfoConfig = state.moduleConfig.neighborInfo, + enabled = state.connected, + onSaveClicked = { neighborInfoInput -> + val config = moduleConfig { neighborInfo = neighborInfoInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun NeighborInfoConfigItemList( + neighborInfoConfig: ModuleConfigProtos.ModuleConfig.NeighborInfoConfig, + enabled: Boolean, + onSaveClicked: (ModuleConfigProtos.ModuleConfig.NeighborInfoConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var neighborInfoInput by rememberSaveable { mutableStateOf(neighborInfoConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Neighbor Info Config") } + + item { + SwitchPreference(title = "Neighbor Info enabled", + checked = neighborInfoInput.enabled, + enabled = enabled, + onCheckedChange = { + neighborInfoInput = neighborInfoInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Update interval (seconds)", + value = neighborInfoInput.updateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + neighborInfoInput = neighborInfoInput.copy { updateInterval = it } + }) + } + + item { + PreferenceFooter( + enabled = neighborInfoInput != neighborInfoConfig, + onCancelClicked = { + focusManager.clearFocus() + neighborInfoInput = neighborInfoConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(neighborInfoInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NeighborInfoConfigPreview() { + NeighborInfoConfigItemList( + neighborInfoConfig = ModuleConfigProtos.ModuleConfig.NeighborInfoConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/NetworkConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/NetworkConfigItemList.kt new file mode 100644 index 000000000..5e965b839 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/NetworkConfigItemList.kt @@ -0,0 +1,289 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.NetworkConfig +import com.geeksville.mesh.R +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditIPv4Preference +import com.geeksville.mesh.ui.components.EditPasswordPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SimpleAlertDialog +import com.geeksville.mesh.ui.components.SwitchPreference +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +@Composable +private fun ScanErrorDialog( + onDismiss: () -> Unit = {} +) = SimpleAlertDialog( + title = R.string.error, + text = R.string.wifi_qr_code_error, + onDismiss = onDismiss, +) + +@Composable +fun NetworkConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + NetworkConfigItemList( + networkConfig = state.radioConfig.network, + enabled = state.connected, + onSaveClicked = { networkInput -> + val config = config { network = networkInput } + viewModel.setConfig(config) + } + ) +} + +@Composable +fun NetworkConfigItemList( + networkConfig: NetworkConfig, + enabled: Boolean, + onSaveClicked: (NetworkConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var networkInput by rememberSaveable { mutableStateOf(networkConfig) } + + var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) } + if (showScanErrorDialog) { + ScanErrorDialog { showScanErrorDialog = false } + } + + fun extractWifiCredentials(qrCode: String) = Regex("""WIFI:S:(.*?);.*?P:(.*?);""") + .find(qrCode)?.destructured + ?.let { (ssid, password) -> ssid to password } + ?: (null to null) + + val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + val (ssid, psk) = extractWifiCredentials(result.contents) + if (ssid != null && psk != null) { + networkInput = networkInput.copy { wifiSsid = ssid; wifiPsk = psk } + } else { + showScanErrorDialog = true + } + } + } + + fun zxingScan() { + val zxingScan = ScanOptions().apply { + setCameraId(0) + setPrompt("") + setBeepEnabled(false) + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + } + barcodeLauncher.launch(zxingScan) + } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Network Config") } + + item { + SwitchPreference(title = "WiFi enabled", + checked = networkInput.wifiEnabled, + enabled = enabled, + onCheckedChange = { networkInput = networkInput.copy { wifiEnabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "SSID", + value = networkInput.wifiSsid, + maxSize = 32, // wifi_ssid max_size:33 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + networkInput = networkInput.copy { wifiSsid = it } + }) + } + + item { + EditPasswordPreference(title = "PSK", + value = networkInput.wifiPsk, + maxSize = 64, // wifi_psk max_size:65 + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { networkInput = networkInput.copy { wifiPsk = it } }) + } + + item { + Button( + onClick = { zxingScan() }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(48.dp), + enabled = enabled, + ) { + Text(text = stringResource(R.string.wifi_qr_code_scan)) + } + } + + item { + EditTextPreference(title = "NTP server", + value = networkInput.ntpServer, + maxSize = 32, // ntp_server max_size:33 + enabled = enabled, + isError = networkInput.ntpServer.isEmpty(), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + networkInput = networkInput.copy { ntpServer = it } + }) + } + + item { + EditTextPreference(title = "rsyslog server", + value = networkInput.rsyslogServer, + maxSize = 32, // rsyslog_server max_size:33 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + networkInput = networkInput.copy { rsyslogServer = it } + }) + } + + item { + SwitchPreference(title = "Ethernet enabled", + checked = networkInput.ethEnabled, + enabled = enabled, + onCheckedChange = { networkInput = networkInput.copy { ethEnabled = it } }) + } + item { Divider() } + + item { + DropDownPreference(title = "IPv4 mode", + enabled = enabled, + items = NetworkConfig.AddressMode.entries + .filter { it != NetworkConfig.AddressMode.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = networkInput.addressMode, + onItemSelected = { networkInput = networkInput.copy { addressMode = it } }) + } + item { Divider() } + + item { + EditIPv4Preference(title = "IP", + value = networkInput.ipv4Config.ip, + enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + val ipv4 = networkInput.ipv4Config.copy { ip = it } + networkInput = networkInput.copy { ipv4Config = ipv4 } + }) + } + + item { + EditIPv4Preference(title = "Gateway", + value = networkInput.ipv4Config.gateway, + enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + val ipv4 = networkInput.ipv4Config.copy { gateway = it } + networkInput = networkInput.copy { ipv4Config = ipv4 } + }) + } + + item { + EditIPv4Preference(title = "Subnet", + value = networkInput.ipv4Config.subnet, + enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + val ipv4 = networkInput.ipv4Config.copy { subnet = it } + networkInput = networkInput.copy { ipv4Config = ipv4 } + }) + } + + item { + EditIPv4Preference(title = "DNS", + value = networkInput.ipv4Config.dns, + enabled = enabled && networkInput.addressMode == NetworkConfig.AddressMode.STATIC, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + val ipv4 = networkInput.ipv4Config.copy { dns = it } + networkInput = networkInput.copy { ipv4Config = ipv4 } + }) + } + + item { + PreferenceFooter( + enabled = networkInput != networkConfig, + onCancelClicked = { + focusManager.clearFocus() + networkInput = networkConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(networkInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NetworkConfigPreview() { + NetworkConfigItemList( + networkConfig = NetworkConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun QrCodeErrorDialogPreview() { + ScanErrorDialog() +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/PacketResponseStateDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/PacketResponseStateDialog.kt new file mode 100644 index 000000000..61dde89df --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/PacketResponseStateDialog.kt @@ -0,0 +1,89 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.ResponseState + +@Composable +fun PacketResponseStateDialog( + state: ResponseState, + onDismiss: () -> Unit = {}, + onComplete: () -> Unit = {}, +) { + AlertDialog( + onDismissRequest = {}, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state is ResponseState.Loading) { + val progress by animateFloatAsState( + targetValue = state.completed.toFloat() / state.total.toFloat(), + label = "progress", + ) + Text("%.0f%%".format(progress * 100)) + LinearProgressIndicator( + progress = progress, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + color = MaterialTheme.colors.onSurface, + ) + if (state.total == state.completed) onComplete() + } + if (state is ResponseState.Success) { + Text(text = stringResource(id = R.string.delivery_confirmed)) + } + if (state is ResponseState.Error) { + Text(text = stringResource(id = R.string.error), minLines = 2) + Text(text = state.error) + } + } + }, + buttons = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + Button( + onClick = onDismiss, + modifier = Modifier.padding(top = 16.dp) + ) { Text(stringResource(R.string.close)) } + } + } + ) +} + +@Preview(showBackground = true) +@Composable +private fun PacketResponseStateDialogPreview() { + PacketResponseStateDialog( + state = ResponseState.Loading( + total = 17, + completed = 5, + ), + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/PaxcounterConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/PaxcounterConfigItemList.kt new file mode 100644 index 000000000..bb4a158fd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/PaxcounterConfigItemList.kt @@ -0,0 +1,127 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun PaxcounterConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + PaxcounterConfigItemList( + paxcounterConfig = state.moduleConfig.paxcounter, + enabled = state.connected, + onSaveClicked = { paxcounterConfigInput -> + val config = moduleConfig { paxcounter = paxcounterConfigInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun PaxcounterConfigItemList( + paxcounterConfig: ModuleConfigProtos.ModuleConfig.PaxcounterConfig, + enabled: Boolean, + onSaveClicked: (ModuleConfigProtos.ModuleConfig.PaxcounterConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var paxcounterInput by rememberSaveable { mutableStateOf(paxcounterConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Paxcounter Config") } + + item { + SwitchPreference(title = "Paxcounter enabled", + checked = paxcounterInput.enabled, + enabled = enabled, + onCheckedChange = { + paxcounterInput = paxcounterInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Update interval (seconds)", + value = paxcounterInput.paxcounterUpdateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + paxcounterInput = paxcounterInput.copy { paxcounterUpdateInterval = it } + }) + } + + item { + EditTextPreference(title = "WiFi RSSI threshold (defaults to -80)", + value = paxcounterInput.wifiThreshold, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + paxcounterInput = paxcounterInput.copy { wifiThreshold = it } + }) + } + + item { + EditTextPreference(title = "BLE RSSI threshold (defaults to -80)", + value = paxcounterInput.bleThreshold, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + paxcounterInput = paxcounterInput.copy { bleThreshold = it } + }) + } + + item { + PreferenceFooter( + enabled = paxcounterInput != paxcounterConfig, + onCancelClicked = { + focusManager.clearFocus() + paxcounterInput = paxcounterConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(paxcounterInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PaxcounterConfigPreview() { + PaxcounterConfigItemList( + paxcounterConfig = ModuleConfigProtos.ModuleConfig.PaxcounterConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt new file mode 100644 index 000000000..846fbab85 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt @@ -0,0 +1,254 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.ConfigProtos.Config.PositionConfig +import com.geeksville.mesh.Position +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.BitwisePreference +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun PositionConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + val node by viewModel.destNode.collectAsStateWithLifecycle() + val currentPosition = Position( + latitude = node?.latitude ?: 0.0, + longitude = node?.longitude ?: 0.0, + altitude = node?.position?.altitude ?: 0, + time = 1, // ignore time for fixed_position + ) + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + PositionConfigItemList( + location = currentPosition, + positionConfig = state.radioConfig.position, + enabled = state.connected, + onSaveClicked = { locationInput, positionInput -> + if (positionInput.fixedPosition) { + if (locationInput != currentPosition) { + viewModel.setFixedPosition(locationInput) + } + } else { + if (state.radioConfig.position.fixedPosition) { + // fixed position changed from enabled to disabled + viewModel.removeFixedPosition() + } + } + val config = config { position = positionInput } + viewModel.setConfig(config) + } + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun PositionConfigItemList( + location: Position, + positionConfig: PositionConfig, + enabled: Boolean, + onSaveClicked: (position: Position, config: PositionConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var locationInput by rememberSaveable { mutableStateOf(location) } + var positionInput by rememberSaveable { mutableStateOf(positionConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Position Config") } + + item { + EditTextPreference(title = "Position broadcast interval (seconds)", + value = positionInput.positionBroadcastSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + positionInput = positionInput.copy { positionBroadcastSecs = it } + }) + } + + item { + SwitchPreference(title = "Smart position enabled", + checked = positionInput.positionBroadcastSmartEnabled, + enabled = enabled, + onCheckedChange = { + positionInput = positionInput.copy { positionBroadcastSmartEnabled = it } + }) + } + item { Divider() } + + if (positionInput.positionBroadcastSmartEnabled) { + item { + EditTextPreference(title = "Smart broadcast minimum distance (meters)", + value = positionInput.broadcastSmartMinimumDistance, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + positionInput = positionInput.copy { broadcastSmartMinimumDistance = it } + }) + } + + item { + EditTextPreference(title = "Smart broadcast minimum interval (seconds)", + value = positionInput.broadcastSmartMinimumIntervalSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + positionInput = positionInput.copy { broadcastSmartMinimumIntervalSecs = it } + }) + } + } + + item { + SwitchPreference(title = "Use fixed position", + checked = positionInput.fixedPosition, + enabled = enabled, + onCheckedChange = { positionInput = positionInput.copy { fixedPosition = it } }) + } + item { Divider() } + + if (positionInput.fixedPosition) { + item { + EditTextPreference(title = "Latitude", + value = locationInput.latitude, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { value -> + if (value >= -90 && value <= 90.0) { + locationInput = locationInput.copy(latitude = value) + } + }) + } + item { + EditTextPreference(title = "Longitude", + value = locationInput.longitude, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { value -> + if (value >= -180 && value <= 180.0) { + locationInput = locationInput.copy(longitude = value) + } + }) + } + item { + EditTextPreference(title = "Altitude (meters)", + value = locationInput.altitude, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { value -> + locationInput = locationInput.copy(altitude = value) + }) + } + } + + item { + DropDownPreference(title = "GPS mode", + enabled = enabled, + items = ConfigProtos.Config.PositionConfig.GpsMode.entries + .filter { it != ConfigProtos.Config.PositionConfig.GpsMode.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = positionInput.gpsMode, + onItemSelected = { positionInput = positionInput.copy { gpsMode = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "GPS update interval (seconds)", + value = positionInput.gpsUpdateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { positionInput = positionInput.copy { gpsUpdateInterval = it } }) + } + + item { + BitwisePreference(title = "Position flags", + value = positionInput.positionFlags, + enabled = enabled, + items = ConfigProtos.Config.PositionConfig.PositionFlags.entries + .filter { it != PositionConfig.PositionFlags.UNSET && it != PositionConfig.PositionFlags.UNRECOGNIZED } + .map { it.number to it.name }, + onItemSelected = { positionInput = positionInput.copy { positionFlags = it } } + ) + } + item { Divider() } + + item { + EditTextPreference(title = "Redefine GPS_RX_PIN", + value = positionInput.rxGpio, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { positionInput = positionInput.copy { rxGpio = it } }) + } + + item { + EditTextPreference(title = "Redefine GPS_TX_PIN", + value = positionInput.txGpio, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { positionInput = positionInput.copy { txGpio = it } }) + } + + item { + EditTextPreference(title = "Redefine PIN_GPS_EN", + value = positionInput.gpsEnGpio, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { positionInput = positionInput.copy { gpsEnGpio = it } }) + } + + item { + PreferenceFooter( + enabled = positionInput != positionConfig || locationInput != location, + onCancelClicked = { + focusManager.clearFocus() + locationInput = location + positionInput = positionConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(locationInput, positionInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PositionConfigPreview() { + PositionConfigItemList( + location = Position(0.0, 0.0, 0), + positionConfig = PositionConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { _, _ -> }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/PowerConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/PowerConfigItemList.kt new file mode 100644 index 000000000..d289833d7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/PowerConfigItemList.kt @@ -0,0 +1,153 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.PowerConfig +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun PowerConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + PowerConfigItemList( + powerConfig = state.radioConfig.power, + enabled = state.connected, + onSaveClicked = { powerInput -> + val config = config { power = powerInput } + viewModel.setConfig(config) + } + ) +} + +@Composable +fun PowerConfigItemList( + powerConfig: PowerConfig, + enabled: Boolean, + onSaveClicked: (PowerConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var powerInput by rememberSaveable { mutableStateOf(powerConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Power Config") } + + item { + SwitchPreference(title = "Enable power saving mode", + checked = powerInput.isPowerSaving, + enabled = enabled, + onCheckedChange = { powerInput = powerInput.copy { isPowerSaving = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Shutdown on battery delay (seconds)", + value = powerInput.onBatteryShutdownAfterSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + powerInput = powerInput.copy { onBatteryShutdownAfterSecs = it } + }) + } + + item { + EditTextPreference(title = "ADC multiplier override ratio", + value = powerInput.adcMultiplierOverride, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { adcMultiplierOverride = it } }) + } + + item { + EditTextPreference(title = "Wait for Bluetooth duration (seconds)", + value = powerInput.waitBluetoothSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { waitBluetoothSecs = it } }) + } + + item { + EditTextPreference(title = "Super deep sleep duration (seconds)", + value = powerInput.sdsSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { sdsSecs = it } }) + } + + item { + EditTextPreference(title = "Light sleep duration (seconds)", + value = powerInput.lsSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { lsSecs = it } }) + } + + item { + EditTextPreference(title = "Minimum wake time (seconds)", + value = powerInput.minWakeSecs, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { minWakeSecs = it } }) + } + + item { + EditTextPreference(title = "Battery INA_2XX I2C address", + value = powerInput.deviceBatteryInaAddress, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { powerInput = powerInput.copy { deviceBatteryInaAddress = it } }) + } + + item { + PreferenceFooter( + enabled = powerInput != powerConfig, + onCancelClicked = { + focusManager.clearFocus() + powerInput = powerConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(powerInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PowerConfigPreview() { + PowerConfigItemList( + powerConfig = PowerConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/RangeTestConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/RangeTestConfigItemList.kt new file mode 100644 index 000000000..2d76f49f3 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/RangeTestConfigItemList.kt @@ -0,0 +1,111 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.RangeTestConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun RangeTestConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + RangeTestConfigItemList( + rangeTestConfig = state.moduleConfig.rangeTest, + enabled = state.connected, + onSaveClicked = { rangeTestInput -> + val config = moduleConfig { rangeTest = rangeTestInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun RangeTestConfigItemList( + rangeTestConfig: RangeTestConfig, + enabled: Boolean, + onSaveClicked: (RangeTestConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var rangeTestInput by rememberSaveable { mutableStateOf(rangeTestConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Range Test Config") } + + item { + SwitchPreference(title = "Range test enabled", + checked = rangeTestInput.enabled, + enabled = enabled, + onCheckedChange = { rangeTestInput = rangeTestInput.copy { this.enabled = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Sender message interval (seconds)", + value = rangeTestInput.sender, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { rangeTestInput = rangeTestInput.copy { sender = it } }) + } + + item { + SwitchPreference(title = "Save .CSV in storage (ESP32 only)", + checked = rangeTestInput.save, + enabled = enabled, + onCheckedChange = { rangeTestInput = rangeTestInput.copy { save = it } }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = rangeTestInput != rangeTestConfig, + onCancelClicked = { + focusManager.clearFocus() + rangeTestInput = rangeTestConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(rangeTestInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RangeTestConfig() { + RangeTestConfigItemList( + rangeTestConfig = RangeTestConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/RemoteHardwareConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/RemoteHardwareConfigItemList.kt new file mode 100644 index 000000000..5ba1a71d7 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/RemoteHardwareConfigItemList.kt @@ -0,0 +1,121 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.RemoteHardwareConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditListPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun RemoteHardwareConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + RemoteHardwareConfigItemList( + remoteHardwareConfig = state.moduleConfig.remoteHardware, + enabled = state.connected, + onSaveClicked = { remoteHardwareInput -> + val config = moduleConfig { remoteHardware = remoteHardwareInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun RemoteHardwareConfigItemList( + remoteHardwareConfig: RemoteHardwareConfig, + enabled: Boolean, + onSaveClicked: (RemoteHardwareConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var remoteHardwareInput by rememberSaveable { mutableStateOf(remoteHardwareConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Remote Hardware Config") } + + item { + SwitchPreference(title = "Remote Hardware enabled", + checked = remoteHardwareInput.enabled, + enabled = enabled, + onCheckedChange = { + remoteHardwareInput = remoteHardwareInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Allow undefined pin access", + checked = remoteHardwareInput.allowUndefinedPinAccess, + enabled = enabled, + onCheckedChange = { + remoteHardwareInput = remoteHardwareInput.copy { allowUndefinedPinAccess = it } + }) + } + item { Divider() } + + item { + EditListPreference(title = "Available pins", + list = remoteHardwareInput.availablePinsList, + maxCount = 4, // available_pins max_count:4 + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValuesChanged = { list -> + remoteHardwareInput = remoteHardwareInput.copy { + availablePins.clear() + availablePins.addAll(list) + } + }) + } + + item { + PreferenceFooter( + enabled = remoteHardwareInput != remoteHardwareConfig, + onCancelClicked = { + focusManager.clearFocus() + remoteHardwareInput = remoteHardwareConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(remoteHardwareInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RemoteHardwareConfigPreview() { + RemoteHardwareConfigItemList( + remoteHardwareConfig = RemoteHardwareConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/SecurityConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/SecurityConfigItemList.kt new file mode 100644 index 000000000..ee38c5aab --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/SecurityConfigItemList.kt @@ -0,0 +1,171 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ConfigProtos.Config.SecurityConfig +import com.geeksville.mesh.config +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.ui.components.EditBase64Preference +import com.geeksville.mesh.ui.components.EditListPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun SecurityConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + SecurityConfigItemList( + securityConfig = state.radioConfig.security, + enabled = state.connected, + onConfirm = { securityInput -> + val config = config { security = securityInput } + viewModel.setConfig(config) + } + ) +} + +@Suppress("LongMethod") +@Composable +fun SecurityConfigItemList( + securityConfig: SecurityConfig, + enabled: Boolean, + onConfirm: (config: SecurityConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var securityInput by rememberSaveable { mutableStateOf(securityConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Security Config") } + + item { + EditBase64Preference( + title = "Public Key", + value = securityInput.publicKey, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = { + if (it.size() == 32) { + securityInput = securityInput.copy { publicKey = it } + } + }, + ) + } + + item { + EditBase64Preference( + title = "Private Key", + value = securityInput.privateKey, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = { + if (it.size() == 32) { + securityInput = securityInput.copy { privateKey = it } + } + }, + ) + } + + item { + EditListPreference( + title = "Admin Key", + list = securityInput.adminKeyList, + maxCount = 3, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValuesChanged = { + securityInput = securityInput.copy { + adminKey.clear() + adminKey.addAll(it) + } + }, + ) + } + + item { + SwitchPreference(title = "Managed Mode", + checked = securityInput.isManaged, + enabled = enabled && securityInput.adminKeyCount > 0, + onCheckedChange = { + securityInput = securityInput.copy { isManaged = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Serial console", + checked = securityInput.serialEnabled, + enabled = enabled, + onCheckedChange = { securityInput = securityInput.copy { serialEnabled = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Debug log API enabled", + checked = securityInput.debugLogApiEnabled, + enabled = enabled, + onCheckedChange = { + securityInput = securityInput.copy { debugLogApiEnabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Legacy Admin channel", + checked = securityInput.adminChannelEnabled, + enabled = enabled, + onCheckedChange = { + securityInput = securityInput.copy { adminChannelEnabled = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = securityInput != securityConfig, + onCancelClicked = { + focusManager.clearFocus() + securityInput = securityConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onConfirm(securityInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SecurityConfigPreview() { + SecurityConfigItemList( + securityConfig = SecurityConfig.getDefaultInstance(), + enabled = true, + onConfirm = {}, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/SerialConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/SerialConfigItemList.kt new file mode 100644 index 000000000..a999c6816 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/SerialConfigItemList.kt @@ -0,0 +1,160 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.SerialConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.DropDownPreference +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun SerialConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + SerialConfigItemList( + serialConfig = state.moduleConfig.serial, + enabled = state.connected, + onSaveClicked = { serialInput -> + val config = moduleConfig { serial = serialInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun SerialConfigItemList( + serialConfig: SerialConfig, + enabled: Boolean, + onSaveClicked: (SerialConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var serialInput by rememberSaveable { mutableStateOf(serialConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Serial Config") } + + item { + SwitchPreference(title = "Serial enabled", + checked = serialInput.enabled, + enabled = enabled, + onCheckedChange = { serialInput = serialInput.copy { this.enabled = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Echo enabled", + checked = serialInput.echo, + enabled = enabled, + onCheckedChange = { serialInput = serialInput.copy { echo = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "RX", + value = serialInput.rxd, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { serialInput = serialInput.copy { rxd = it } }) + } + + item { + EditTextPreference(title = "TX", + value = serialInput.txd, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { serialInput = serialInput.copy { txd = it } }) + } + + item { + DropDownPreference(title = "Serial baud rate", + enabled = enabled, + items = SerialConfig.Serial_Baud.entries + .filter { it != SerialConfig.Serial_Baud.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = serialInput.baud, + onItemSelected = { serialInput = serialInput.copy { baud = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Timeout", + value = serialInput.timeout, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { serialInput = serialInput.copy { timeout = it } }) + } + + item { + DropDownPreference(title = "Serial mode", + enabled = enabled, + items = SerialConfig.Serial_Mode.entries + .filter { it != SerialConfig.Serial_Mode.UNRECOGNIZED } + .map { it to it.name }, + selectedItem = serialInput.mode, + onItemSelected = { serialInput = serialInput.copy { mode = it } }) + } + item { Divider() } + + item { + SwitchPreference(title = "Override console serial port", + checked = serialInput.overrideConsoleSerialPort, + enabled = enabled, + onCheckedChange = { + serialInput = serialInput.copy { overrideConsoleSerialPort = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = serialInput != serialConfig, + onCancelClicked = { + focusManager.clearFocus() + serialInput = serialConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(serialInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SerialConfigPreview() { + SerialConfigItemList( + serialConfig = SerialConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/StoreForwardConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/StoreForwardConfigItemList.kt new file mode 100644 index 000000000..141281b3c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/StoreForwardConfigItemList.kt @@ -0,0 +1,142 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.StoreForwardConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun StoreForwardConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + StoreForwardConfigItemList( + storeForwardConfig = state.moduleConfig.storeForward, + enabled = state.connected, + onSaveClicked = { storeForwardInput -> + val config = moduleConfig { storeForward = storeForwardInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun StoreForwardConfigItemList( + storeForwardConfig: StoreForwardConfig, + enabled: Boolean, + onSaveClicked: (StoreForwardConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var storeForwardInput by rememberSaveable { mutableStateOf(storeForwardConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Store & Forward Config") } + + item { + SwitchPreference(title = "Store & Forward enabled", + checked = storeForwardInput.enabled, + enabled = enabled, + onCheckedChange = { + storeForwardInput = storeForwardInput.copy { this.enabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Heartbeat", + checked = storeForwardInput.heartbeat, + enabled = enabled, + onCheckedChange = { storeForwardInput = storeForwardInput.copy { heartbeat = it } }) + } + item { Divider() } + + item { + EditTextPreference(title = "Number of records", + value = storeForwardInput.records, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { storeForwardInput = storeForwardInput.copy { records = it } }) + } + + item { + EditTextPreference(title = "History return max", + value = storeForwardInput.historyReturnMax, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + storeForwardInput = storeForwardInput.copy { historyReturnMax = it } + }) + } + + item { + EditTextPreference(title = "History return window", + value = storeForwardInput.historyReturnWindow, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + storeForwardInput = storeForwardInput.copy { historyReturnWindow = it } + }) + } + + item { + SwitchPreference( + title = "Server", + checked = storeForwardInput.isServer, + enabled = enabled, + onCheckedChange = { storeForwardInput = storeForwardInput.copy { isServer = it } }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = storeForwardInput != storeForwardConfig, + onCancelClicked = { + focusManager.clearFocus() + storeForwardInput = storeForwardConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(storeForwardInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun StoreForwardConfigPreview() { + StoreForwardConfigItemList( + storeForwardConfig = StoreForwardConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/TelemetryConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/TelemetryConfigItemList.kt new file mode 100644 index 000000000..e4734b7ec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/TelemetryConfigItemList.kt @@ -0,0 +1,187 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.TelemetryConfig +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.SwitchPreference + +@Composable +fun TelemetryConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + TelemetryConfigItemList( + telemetryConfig = state.moduleConfig.telemetry, + enabled = state.connected, + onSaveClicked = { telemetryInput -> + val config = moduleConfig { telemetry = telemetryInput } + viewModel.setModuleConfig(config) + } + ) +} + +@Composable +fun TelemetryConfigItemList( + telemetryConfig: TelemetryConfig, + enabled: Boolean, + onSaveClicked: (TelemetryConfig) -> Unit, +) { + val focusManager = LocalFocusManager.current + var telemetryInput by rememberSaveable { mutableStateOf(telemetryConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "Telemetry Config") } + + item { + EditTextPreference(title = "Device metrics update interval (seconds)", + value = telemetryInput.deviceUpdateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + telemetryInput = telemetryInput.copy { deviceUpdateInterval = it } + }) + } + + item { + EditTextPreference(title = "Environment metrics update interval (seconds)", + value = telemetryInput.environmentUpdateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + telemetryInput = telemetryInput.copy { environmentUpdateInterval = it } + }) + } + + item { + SwitchPreference(title = "Environment metrics module enabled", + checked = telemetryInput.environmentMeasurementEnabled, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { environmentMeasurementEnabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Environment metrics on-screen enabled", + checked = telemetryInput.environmentScreenEnabled, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { environmentScreenEnabled = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Environment metrics use Fahrenheit", + checked = telemetryInput.environmentDisplayFahrenheit, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { environmentDisplayFahrenheit = it } + }) + } + item { Divider() } + + item { + SwitchPreference(title = "Air quality metrics module enabled", + checked = telemetryInput.airQualityEnabled, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { airQualityEnabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Air quality metrics update interval (seconds)", + value = telemetryInput.airQualityInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + telemetryInput = telemetryInput.copy { airQualityInterval = it } + }) + } + + item { + SwitchPreference(title = "Power metrics module enabled", + checked = telemetryInput.powerMeasurementEnabled, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { powerMeasurementEnabled = it } + }) + } + item { Divider() } + + item { + EditTextPreference(title = "Power metrics update interval (seconds)", + value = telemetryInput.powerUpdateInterval, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + telemetryInput = telemetryInput.copy { powerUpdateInterval = it } + }) + } + + item { + SwitchPreference(title = "Power metrics on-screen enabled", + checked = telemetryInput.powerScreenEnabled, + enabled = enabled, + onCheckedChange = { + telemetryInput = telemetryInput.copy { powerScreenEnabled = it } + }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = telemetryInput != telemetryConfig, + onCancelClicked = { + focusManager.clearFocus() + telemetryInput = telemetryConfig + }, + onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(telemetryInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TelemetryConfigPreview() { + TelemetryConfigItemList( + telemetryConfig = TelemetryConfig.getDefaultInstance(), + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt new file mode 100644 index 000000000..8e23bd519 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt @@ -0,0 +1,147 @@ +package com.geeksville.mesh.ui.components.config + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.copy +import com.geeksville.mesh.model.RadioConfigViewModel +import com.geeksville.mesh.model.getInitials +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.PreferenceFooter +import com.geeksville.mesh.ui.components.RegularPreference +import com.geeksville.mesh.ui.components.SwitchPreference +import com.geeksville.mesh.user + +@Composable +fun UserConfigScreen( + viewModel: RadioConfigViewModel = hiltViewModel(), +) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + + if (state.responseState.isWaiting()) { + PacketResponseStateDialog( + state = state.responseState, + onDismiss = viewModel::clearPacketResponse, + ) + } + + UserConfigItemList( + userConfig = state.userConfig, + enabled = true, + onSaveClicked = viewModel::setOwner, + ) +} + +@Composable +fun UserConfigItemList( + userConfig: MeshProtos.User, + enabled: Boolean, + onSaveClicked: (MeshProtos.User) -> Unit, +) { + val focusManager = LocalFocusManager.current + var userInput by rememberSaveable { mutableStateOf(userConfig) } + + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { PreferenceCategory(text = "User Config") } + + item { + RegularPreference(title = "Node ID", + subtitle = userInput.id, + onClick = {}) + } + item { Divider() } + + item { + EditTextPreference(title = "Long name", + value = userInput.longName, + maxSize = 39, // long_name max_size:40 + enabled = enabled, + isError = userInput.longName.isEmpty(), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + userInput = userInput.copy { longName = it } + if (getInitials(it).toByteArray().size <= 4) { // short_name max_size:5 + userInput = userInput.copy { shortName = getInitials(it) } + } + }) + } + + item { + EditTextPreference(title = "Short name", + value = userInput.shortName, + maxSize = 4, // short_name max_size:5 + enabled = enabled, + isError = userInput.shortName.isEmpty(), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { userInput = userInput.copy { shortName = it } }) + } + + item { + RegularPreference(title = "Hardware model", + subtitle = userInput.hwModel.name, + onClick = {}) + } + item { Divider() } + + item { + SwitchPreference(title = "Licensed amateur radio", + checked = userInput.isLicensed, + enabled = enabled, + onCheckedChange = { userInput = userInput.copy { isLicensed = it } }) + } + item { Divider() } + + item { + PreferenceFooter( + enabled = userInput != userConfig, + onCancelClicked = { + focusManager.clearFocus() + userInput = userConfig + }, onSaveClicked = { + focusManager.clearFocus() + onSaveClicked(userInput) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun UserConfigPreview() { + UserConfigItemList( + userConfig = user { + id = "!a280d9c8" + longName = "Meshtastic d9c8" + shortName = "d9c8" + hwModel = MeshProtos.HardwareModel.RAK4631 + isLicensed = false + }, + enabled = true, + onSaveClicked = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/compose/ElevationInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/compose/ElevationInfo.kt new file mode 100644 index 000000000..05ab3c2fd --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/compose/ElevationInfo.kt @@ -0,0 +1,46 @@ +package com.geeksville.mesh.ui.compose + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits +import com.geeksville.mesh.util.toString + +@Composable +fun ElevationInfo( + modifier: Modifier = Modifier, + altitude: Float, + system: DisplayUnits, + suffix: String +) { + val annotatedString = buildAnnotatedString { + append(altitude.toString(system)) + MaterialTheme.typography.overline.toSpanStyle().let { style -> + withStyle(style) { + append(" $suffix") + } + } + } + + Text( + modifier = modifier, + fontSize = MaterialTheme.typography.button.fontSize, + text = annotatedString, + ) +} + +@Composable +@Preview +fun ElevationInfoPreview() { + MaterialTheme { + ElevationInfo( + altitude = 100.0f, + system = DisplayUnits.METRIC, + suffix = "ASL" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt new file mode 100644 index 000000000..7de5e233f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/compose/SatelliteCountInfo.kt @@ -0,0 +1,51 @@ +package com.geeksville.mesh.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.theme.AppTheme + +@Composable +fun SatelliteCountInfo( + modifier: Modifier = Modifier, + satCount: Int, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.height(18.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_satellite), + contentDescription = null, + tint = MaterialTheme.colors.onSurface, + ) + Text( + text = "$satCount", + fontSize = MaterialTheme.typography.button.fontSize, + color = MaterialTheme.colors.onSurface, + ) + } +} + +@PreviewLightDark +@Composable +fun SatelliteCountInfoPreview() { + AppTheme { + SatelliteCountInfo( + satCount = 5, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt b/app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt new file mode 100644 index 000000000..a9b14caf2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/CacheLayout.kt @@ -0,0 +1,94 @@ +package com.geeksville.mesh.ui.map + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun CacheLayout( + cacheEstimate: String, + onExecuteJob: () -> Unit, + onCancelDownload: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(color = MaterialTheme.colors.background) + .padding(8.dp), + ) { + Text( + text = stringResource(id = R.string.map_select_download_region), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h5, + color = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.map_tile_download_estimate) + " " + cacheEstimate, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground.copy(alpha = ContentAlpha.medium), + ) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + Button( + onClick = onCancelDownload, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(id = R.string.cancel), + color = MaterialTheme.colors.onPrimary, + ) + } + Button( + onClick = onExecuteJob, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(id = R.string.map_start_download), + color = MaterialTheme.colors.onPrimary, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CacheLayoutPreview() { + CacheLayout( + cacheEstimate = "100 tiles", + onExecuteJob = { }, + onCancelDownload = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt b/app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt new file mode 100644 index 000000000..21b7e6ea5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/DownloadButton.kt @@ -0,0 +1,52 @@ +package com.geeksville.mesh.ui.map + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.stringResource +import com.geeksville.mesh.R + +@Composable +internal fun DownloadButton( + enabled: Boolean, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = enabled, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing) + ), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing) + ) + ) { + FloatingActionButton( + onClick = onClick, + backgroundColor = MaterialTheme.colors.primary, + ) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.map_download_region), + modifier = Modifier.scale(1.25f), + ) + } + } +} + +//@Preview(showBackground = true) +//@Composable +//private fun DownloadButtonPreview() { +// DownloadButton(true, onClick = {}) +//} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt new file mode 100644 index 000000000..01c1f3678 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt @@ -0,0 +1,215 @@ +package com.geeksville.mesh.ui.map + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.emoji2.emojipicker.EmojiPickerView +import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter +import com.geeksville.mesh.MeshProtos.Waypoint +import com.geeksville.mesh.R +import com.geeksville.mesh.copy +import com.geeksville.mesh.ui.components.EditTextPreference +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.CustomRecentEmojiProvider +import com.geeksville.mesh.waypoint + +@Suppress("LongMethod") +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun EditWaypointDialog( + waypoint: Waypoint, + onSendClicked: (Waypoint) -> Unit, + onDeleteClicked: (Waypoint) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + var waypointInput by remember { mutableStateOf(waypoint) } + val title = if (waypoint.id == 0) R.string.waypoint_new else R.string.waypoint_edit + val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon + var showEmojiPickerView by remember { mutableStateOf(false) } + + if (!showEmojiPickerView) AlertDialog( + onDismissRequest = onDismissRequest, + shape = RoundedCornerShape(16.dp), + backgroundColor = MaterialTheme.colors.background, + text = { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.h6.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + EditTextPreference( + title = stringResource(R.string.name), + value = waypointInput.name, + maxSize = 29, // name max_size:30 + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { /*TODO*/ }), + onValueChanged = { waypointInput = waypointInput.copy { name = it } }, + trailingIcon = { + IconButton(onClick = { showEmojiPickerView = true }) { + Text( + text = String(Character.toChars(emoji)), + modifier = Modifier + .background(MaterialTheme.colors.background, CircleShape) + .padding(4.dp), + fontSize = 24.sp, + color = Color.Unspecified.copy(alpha = 1f), + ) + } + }, + ) + EditTextPreference(title = stringResource(R.string.description), + value = waypointInput.description, + maxSize = 99, // description max_size:100 + enabled = true, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { /*TODO*/ }), + onValueChanged = { waypointInput = waypointInput.copy { description = it } } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .size(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_twotone_lock_24), + contentDescription = stringResource(R.string.locked), + ) + Text(stringResource(R.string.locked)) + Switch( + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + checked = waypointInput.lockedTo != 0, + onCheckedChange = { + waypointInput = + waypointInput.copy { lockedTo = if (it) 1 else 0 } + } + ) + } + } + }, + buttons = { + FlowRow( + modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.Center, + ) { + TextButton( + modifier = modifier.weight(1f), + onClick = onDismissRequest + ) { Text(stringResource(R.string.cancel)) } + if (waypoint.id != 0) { + Button( + modifier = modifier.weight(1f), + onClick = { onDeleteClicked(waypointInput) }, + enabled = waypointInput.name.isNotEmpty(), + ) { Text(stringResource(R.string.delete)) } + } + Button( + modifier = modifier.weight(1f), + onClick = { onSendClicked(waypointInput) }, + enabled = waypointInput.name.isNotEmpty(), + ) { Text(stringResource(R.string.send)) } + } + }, + ) else { + Column( + verticalArrangement = Arrangement.Bottom + ) { + BackHandler { + showEmojiPickerView = false + } + + AndroidView( + factory = { context -> + EmojiPickerView(context).apply { + clipToOutline = true + setRecentEmojiProvider( + RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context)) + ) + setOnEmojiPickedListener { emoji -> + showEmojiPickerView = false + waypointInput = waypointInput.copy { icon = emoji.emoji.codePointAt(0) } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.4f) + .background(MaterialTheme.colors.background) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditWaypointFormPreview() { + AppTheme { + EditWaypointDialog( + waypoint = waypoint { + id = 123 + name = "Test 123" + description = "This is only a test" + icon = 128169 + }, + onSendClicked = { }, + onDeleteClicked = { }, + onDismissRequest = { }, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapButton.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapButton.kt new file mode 100644 index 000000000..8eacd324e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapButton.kt @@ -0,0 +1,74 @@ +package com.geeksville.mesh.ui.map + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.theme.AppTheme + +@Composable +fun MapButton( + icon: ImageVector, + @StringRes contentDescription: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + MapButton( + icon = icon, + contentDescription = stringResource(contentDescription), + modifier = modifier, + enabled = enabled, + onClick = onClick, + ) +} + +@Composable +fun MapButton( + icon: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.size(48.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface.copy(alpha = 1f), + contentColor = MaterialTheme.colors.onSurface, + ), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.scale(scale = 1.5f), + ) + } +} + +@PreviewLightDark +@Composable +private fun MapButtonPreview() { + AppTheme { + MapButton( + icon = Icons.Outlined.Layers, + contentDescription = R.string.map_style_selection, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt new file mode 100644 index 000000000..6e7277bb0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -0,0 +1,659 @@ +package com.geeksville.mesh.ui.map + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.layout.Arrangement +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.material.Scaffold +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.MeshProtos.Waypoint +import com.geeksville.mesh.R +import com.geeksville.mesh.android.BuildUtils.debug +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.android.getLocationPermissions +import com.geeksville.mesh.android.gpsDisabled +import com.geeksville.mesh.android.hasGps +import com.geeksville.mesh.android.hasLocationPermission +import com.geeksville.mesh.copy +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.map.CustomTileSource +import com.geeksville.mesh.model.map.MarkerWithLabel +import com.geeksville.mesh.model.map.clustering.RadiusMarkerClusterer +import com.geeksville.mesh.ui.ScreenFragment +import com.geeksville.mesh.ui.theme.AppTheme +import com.geeksville.mesh.util.SqlTileWriterExt +import com.geeksville.mesh.util.addCopyright +import com.geeksville.mesh.util.addScaleBarOverlay +import com.geeksville.mesh.util.createLatLongGrid +import com.geeksville.mesh.util.formatAgo +import com.geeksville.mesh.util.zoomIn +import com.geeksville.mesh.waypoint +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable +import org.osmdroid.config.Configuration +import org.osmdroid.events.MapEventsReceiver +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.tileprovider.cachemanager.CacheManager +import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter +import org.osmdroid.tileprovider.tilesource.ITileSource +import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase +import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.MapEventsOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon +import org.osmdroid.views.overlay.infowindow.InfoWindow +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay +import java.io.File +import java.text.DateFormat + +@AndroidEntryPoint +class MapFragment : ScreenFragment("Map Fragment"), Logging { + + private val model: UIViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + MapView(model) + } + } + } + } +} + +@Composable +private fun MapView.UpdateMarkers( + nodeMarkers: List, + waypointMarkers: List, + nodeClusterer: RadiusMarkerClusterer +) { + debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints") + overlays.removeAll { it is MarkerWithLabel } + // overlays.addAll(nodeMarkers + waypointMarkers) + overlays.addAll(waypointMarkers) + nodeClusterer.items.clear() + nodeClusterer.items.addAll(nodeMarkers) + nodeClusterer.invalidate() +} + +// private fun addWeatherLayer() { +// if (map.tileProvider.tileSource.name() +// .equals(CustomTileSource.getTileSource("ESRI World TOPO").name()) +// ) { +// val layer = TilesOverlay( +// MapTileProviderBasic( +// activity, +// CustomTileSource.OPENWEATHER_RADAR +// ), context +// ) +// layer.loadingBackgroundColor = Color.TRANSPARENT +// layer.loadingLineColor = Color.TRANSPARENT +// map.overlayManager.add(layer) +// } +// } + +private fun cacheManagerCallback( + onTaskComplete: () -> Unit, + onTaskFailed: (Int) -> Unit, +) = object : CacheManager.CacheManagerCallback { + override fun onTaskComplete() { + onTaskComplete() + } + + override fun onTaskFailed(errors: Int) { + onTaskFailed(errors) + } + + override fun updateProgress( + progress: Int, + currentZoomLevel: Int, + zoomMin: Int, + zoomMax: Int + ) { + // NOOP since we are using the build in UI + } + + override fun downloadStarted() { + // NOOP since we are using the build in UI + } + + override fun setPossibleTilesInArea(total: Int) { + // NOOP since we are using the build in UI + } +} + +private fun Context.purgeTileSource(onResult: (String) -> Unit) { + val cache = SqlTileWriterExt() + val builder = MaterialAlertDialogBuilder(this) + builder.setTitle(R.string.map_tile_source) + val sources = cache.sources + val sourceList = mutableListOf() + for (i in sources.indices) { + sourceList.add(sources[i].source as String) + } + val selected: BooleanArray? = null + val selectedList = mutableListOf() + builder.setMultiChoiceItems( + sourceList.toTypedArray(), + selected + ) { _, i, b -> + if (b) { + selectedList.add(i) + } else { + selectedList.remove(i) + } + } + builder.setPositiveButton(R.string.clear) { _, _ -> + for (x in selectedList) { + val item = sources[x] + val b = cache.purgeCache(item.source) + onResult( + if (b) { + getString(R.string.map_purge_success, item.source) + } else { + getString(R.string.map_purge_fail) + } + ) + } + } + builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() } + builder.show() +} + +@Composable +fun MapView( + model: UIViewModel = viewModel(), +) { + // UI Elements + var cacheEstimate by remember { mutableStateOf("") } + + // constants + val prefsName = "org.geeksville.osm.prefs" + val mapStyleId = "map_style_id" + + var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } + var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } + + // Map Elements + var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } + var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } + + val context = LocalContext.current + val density = LocalDensity.current + val mPrefs = remember { context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) } + + val haptic = LocalHapticFeedback.current + fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) + + val hasGps = remember { context.hasGps() } + + val cameraView = remember { + val geoPoints = model.nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } + BoundingBox.fromGeoPoints(geoPoints) + } + val map = rememberMapViewWithLifecycle(cameraView) + + val nodeClusterer = remember { RadiusMarkerClusterer(context) } + + fun MapView.toggleMyLocation() { + if (context.gpsDisabled()) { + debug("Telling user we need location turned on for MyLocationNewOverlay") + model.showSnackbar(R.string.location_disabled) + return + } + debug("user clicked MyLocationNewOverlay ${myLocationOverlay == null}") + if (myLocationOverlay == null) { + myLocationOverlay = MyLocationNewOverlay(this).apply { + enableMyLocation() + enableFollowLocation() + getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot_24)?.let { + setPersonIcon(it) + setPersonAnchor(0.5f, 0.5f) + } + getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation_24)?.let { + setDirectionIcon(it) + setDirectionAnchor(0.5f, 0.5f) + } + } + overlays.add(myLocationOverlay) + } else { + myLocationOverlay?.apply { + disableMyLocation() + disableFollowLocation() + } + overlays.remove(myLocationOverlay) + myLocationOverlay = null + } + } + + val requestPermissionAndToggleLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.entries.all { it.value }) map.toggleMyLocation() + } + + val nodes by model.nodeList.collectAsStateWithLifecycle() + val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap()) + + var showDownloadButton: Boolean by remember { mutableStateOf(false) } + var showEditWaypointDialog by remember { mutableStateOf(null) } + var showCurrentCacheInfo by remember { mutableStateOf(false) } + + val markerIcon = remember { + AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) + } + + fun MapView.onNodesChanged(nodes: Collection): List { + val nodesWithPosition = nodes.filter { it.validPosition != null } + val ourNode = model.ourNodeInfo.value + val gpsFormat = model.config.display.gpsFormat.number + val displayUnits = model.config.display.units.number + return nodesWithPosition.map { node -> + val (p, u) = node.position to node.user + val nodePosition = GeoPoint(node.latitude, node.longitude) + MarkerWithLabel( + mapView = this, + label = "${u.shortName} ${formatAgo(p.time)}" + ).apply { + id = u.id + title = "${u.longName} ${node.batteryStr}" + snippet = node.gpsString(gpsFormat) + ourNode?.distanceStr(node, displayUnits)?.let { dist -> + subDescription = + context.getString(R.string.map_subDescription, ourNode.bearing(node), dist) + } + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + position = nodePosition + icon = markerIcon + + setOnLongClickListener { + performHapticFeedback() + model.focusUserNode(node) + true + } + setNodeColors(node.colors) + setPrecisionBits(p.precisionBits) + } + } + } + + fun showDeleteMarkerDialog(waypoint: Waypoint) { + val builder = MaterialAlertDialogBuilder(context) + builder.setTitle(R.string.waypoint_delete) + builder.setNeutralButton(R.string.cancel) { _, _ -> + debug("User canceled marker delete dialog") + } + builder.setNegativeButton(R.string.delete_for_me) { _, _ -> + debug("User deleted waypoint ${waypoint.id} for me") + model.deleteWaypoint(waypoint.id) + } + if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) { + builder.setPositiveButton(R.string.delete_for_everyone) { _, _ -> + debug("User deleted waypoint ${waypoint.id} for everyone") + model.sendWaypoint(waypoint.copy { expire = 1 }) + model.deleteWaypoint(waypoint.id) + } + } + val dialog = builder.show() + for (button in setOf( + androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, + androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, + androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE + )) with(dialog.getButton(button)) { textSize = 12F; isAllCaps = false } + } + + fun showMarkerLongPressDialog(id: Int) { + performHapticFeedback() + debug("marker long pressed id=$id") + val waypoint = waypoints[id]?.data?.waypoint ?: return + // edit only when unlocked or lockedTo myNodeNum + if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) { + showEditWaypointDialog = waypoint + } else { + showDeleteMarkerDialog(waypoint) + } + } + + fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL) { + context.getString(R.string.you) + } else { + model.getUser(id).longName + } + + fun MapView.onWaypointChanged(waypoints: Collection): List { + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + return waypoints.mapNotNull { waypoint -> + val pt = waypoint.data.waypoint ?: return@mapNotNull null + val lock = if (pt.lockedTo != 0) "\uD83D\uDD12" else "" + val time = dateFormat.format(waypoint.received_time) + val label = pt.name + " " + formatAgo((waypoint.received_time / 1000).toInt()) + val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) + MarkerWithLabel(this, label, emoji).apply { + id = "${pt.id}" + title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" + snippet = "[$time] " + pt.description + position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) + setVisible(false) + setOnLongClickListener { + showMarkerLongPressDialog(pt.id) + true + } + } + } + } + + LaunchedEffect(showCurrentCacheInfo) { + if (!showCurrentCacheInfo) return@LaunchedEffect + model.showSnackbar(R.string.calculating) + val cacheManager = CacheManager(map) // Make sure CacheManager has latest from map + val cacheCapacity = cacheManager.cacheCapacity() + val currentCacheUsage = cacheManager.currentCacheUsage() + + val mapCacheInfoText = context.getString( + R.string.map_cache_info, + cacheCapacity / (1024.0 * 1024.0), + currentCacheUsage / (1024.0 * 1024.0) + ) + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.map_cache_manager) + .setMessage(mapCacheInfoText) + .setPositiveButton(R.string.close) { dialog, _ -> + showCurrentCacheInfo = false + dialog.dismiss() + } + .show() + } + + val mapEventsReceiver = object : MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { + InfoWindow.closeAllInfoWindowsOn(map) + return true + } + + override fun longPressHelper(p: GeoPoint): Boolean { + performHapticFeedback() + val enabled = model.isConnected() && downloadRegionBoundingBox == null + + if (enabled) showEditWaypointDialog = waypoint { + latitudeI = (p.latitude * 1e7).toInt() + longitudeI = (p.longitude * 1e7).toInt() + } + return true + } + } + + fun MapView.drawOverlays() { + if (overlays.none { it is MapEventsOverlay }) { + overlays.add(0, MapEventsOverlay(mapEventsReceiver)) + } + if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { + overlays.add(myLocationOverlay) + } + if (overlays.none { it is RadiusMarkerClusterer }) { + overlays.add(nodeClusterer) + } + + addCopyright() // Copyright is required for certain map sources + addScaleBarOverlay(density) + createLatLongGrid(false) + + invalidate() + } + + with(map) { + UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) + } + + fun loadOnlineTileSourceBase(): ITileSource { + val id = mPrefs.getInt(mapStyleId, 0) + debug("mapStyleId from prefs: $id") + return CustomTileSource.getTileSource(id).also { + zoomLevelMax = it.maximumZoomLevel.toDouble() + showDownloadButton = + if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false + } + } + + /** + * Creates Box overlay showing what area can be downloaded + */ + fun MapView.generateBoxOverlay() { + overlays.removeAll { it is Polygon } + val zoomFactor = 1.3 // zoom difference between view and download area polygon + zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) + downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) + val polygon = Polygon().apply { + points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { + GeoPoint(it.latitude, it.longitude) + } + } + overlays.add(polygon) + invalidate() + val tileCount: Int = CacheManager(this).possibleTilesInArea( + downloadRegionBoundingBox, + zoomLevelMin.toInt(), + zoomLevelMax.toInt(), + ) + cacheEstimate = context.getString(R.string.map_cache_tiles, tileCount) + } + + val boxOverlayListener = object : MapListener { + override fun onScroll(event: ScrollEvent): Boolean { + if (downloadRegionBoundingBox != null) { + event.source.generateBoxOverlay() + } + return true + } + + override fun onZoom(event: ZoomEvent): Boolean { + return false + } + } + + fun startDownload() { + val boundingBox = downloadRegionBoundingBox ?: return + try { + val outputName = buildString { + append(Configuration.getInstance().osmdroidBasePath.absolutePath) + append(File.separator) + append("mainFile.sqlite") // TODO: Accept filename input param from user + } + val writer = SqliteArchiveTileWriter(outputName) + // Make sure cacheManager has latest from map + val cacheManager = CacheManager(map, writer) + // this triggers the download + cacheManager.downloadAreaAsync( + context, + boundingBox, + zoomLevelMin.toInt(), + zoomLevelMax.toInt(), + cacheManagerCallback( + onTaskComplete = { + model.showSnackbar(R.string.map_download_complete) + writer.onDetach() + }, + onTaskFailed = { errors -> + model.showSnackbar(context.getString(R.string.map_download_errors, errors)) + writer.onDetach() + }, + ) + ) + } catch (ex: TileSourcePolicyException) { + debug("Tile source does not allow archiving: ${ex.message}") + } catch (ex: Exception) { + debug("Tile source exception: ${ex.message}") + } + } + + fun showMapStyleDialog() { + val builder = MaterialAlertDialogBuilder(context) + val mapStyles: Array = CustomTileSource.mTileSources.values.toTypedArray() + + val mapStyleInt = mPrefs.getInt(mapStyleId, 0) + builder.setSingleChoiceItems(mapStyles, mapStyleInt) { dialog, which -> + debug("Set mapStyleId pref to $which") + mPrefs.edit().putInt(mapStyleId, which).apply() + dialog.dismiss() + map.setTileSource(loadOnlineTileSourceBase()) + } + val dialog = builder.create() + dialog.show() + } + + fun Context.showCacheManagerDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.map_offline_manager) + .setItems( + arrayOf( + getString(R.string.map_cache_size), + getString(R.string.map_download_region), + getString(R.string.map_clear_tiles), + getString(R.string.cancel) + ) + ) { dialog, which -> + when (which) { + 0 -> showCurrentCacheInfo = true + 1 -> { + map.generateBoxOverlay() + dialog.dismiss() + } + + 2 -> purgeTileSource { model.showSnackbar(it) } + else -> dialog.dismiss() + } + }.show() + } + + Scaffold( + floatingActionButton = { + DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { + context.showCacheManagerDialog() + } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + AndroidView( + factory = { + map.apply { + setTileSource(loadOnlineTileSourceBase()) + setDestroyMode(false) // keeps map instance alive when in the background + addMapListener(boxOverlayListener) + } + }, + modifier = Modifier.fillMaxSize(), + update = { map -> map.drawOverlays() }, + ) + if (downloadRegionBoundingBox != null) CacheLayout( + cacheEstimate = cacheEstimate, + onExecuteJob = { startDownload() }, + onCancelDownload = { + downloadRegionBoundingBox = null + map.overlays.removeAll { it is Polygon } + map.invalidate() + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) else Column( + modifier = Modifier + .padding(top = 16.dp, end = 16.dp) + .align(Alignment.TopEnd), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MapButton( + onClick = ::showMapStyleDialog, + icon = Icons.Outlined.Layers, + contentDescription = R.string.map_style_selection, + ) + MapButton( + enabled = hasGps, + icon = if (myLocationOverlay == null) { + Icons.Outlined.MyLocation + } else { + Icons.Default.LocationDisabled + }, + contentDescription = null, + ) { + if (context.hasLocationPermission()) { + map.toggleMyLocation() + } else { + requestPermissionAndToggleLauncher.launch(context.getLocationPermissions()) + } + } + } + } + } + + if (showEditWaypointDialog != null) { + EditWaypointDialog( + waypoint = showEditWaypointDialog ?: return, + onSendClicked = { waypoint -> + debug("User clicked send waypoint ${waypoint.id}") + showEditWaypointDialog = null + model.sendWaypoint(waypoint.copy { + if (id == 0) id = model.generatePacketId() ?: return@EditWaypointDialog + expire = Int.MAX_VALUE // TODO add expire picker + lockedTo = if (waypoint.lockedTo != 0) model.myNodeNum ?: 0 else 0 + }) + }, + onDeleteClicked = { waypoint -> + debug("User clicked delete waypoint ${waypoint.id}") + showEditWaypointDialog = null + showDeleteMarkerDialog(waypoint) + }, + onDismissRequest = { + debug("User clicked cancel marker edit dialog") + showEditWaypointDialog = null + }, + ) + } +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt similarity index 50% rename from app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt rename to app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt index c16d87163..7605f7a8f 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapViewWithLifecycle.kt @@ -1,21 +1,8 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map +package com.geeksville.mesh.ui.map +import android.annotation.SuppressLint +import android.content.Context +import android.os.PowerManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -26,62 +13,57 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.android.BuildUtils.errormsg +import com.geeksville.mesh.util.requiredZoomLevel import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView -private const val MIN_ZOOM_LEVEL = 1.5 -private const val MAX_ZOOM_LEVEL = 20.0 -private const val DEFAULT_ZOOM_LEVEL = 15.0 - -@Suppress("MagicNumber") -@Composable -fun rememberMapViewWithLifecycle( - applicationId: String, - box: BoundingBox, - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - val zoom = - if (box.requiredZoomLevel().isFinite()) { - (box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL) - } else { - DEFAULT_ZOOM_LEVEL - } - val center = GeoPoint(box.centerLatitude, box.centerLongitude) - return rememberMapViewWithLifecycle( - applicationId = applicationId, - zoomLevel = zoom, - mapCenter = center, - tileSource = tileSource, - ) +@SuppressLint("WakelockTimeout") +private fun PowerManager.WakeLock.safeAcquire() { + if (!isHeld) try { + acquire() + } catch (e: SecurityException) { + errormsg("WakeLock permission exception: ${e.message}") + } catch (e: IllegalStateException) { + errormsg("WakeLock acquire() exception: ${e.message}") + } +} + +private fun PowerManager.WakeLock.safeRelease() { + if (isHeld) try { + release() + } catch (e: IllegalStateException) { + errormsg("WakeLock release() exception: ${e.message}") + } +} + +private const val MinZoomLevel = 1.5 +private const val MaxZoomLevel = 20.0 + +@Composable +internal fun rememberMapViewWithLifecycle(box: BoundingBox): MapView { + val zoom = box.requiredZoomLevel() + val center = GeoPoint(box.centerLatitude, box.centerLongitude) + return rememberMapViewWithLifecycle(zoom, center) } -@Suppress("LongMethod") @Composable internal fun rememberMapViewWithLifecycle( - applicationId: String, - zoomLevel: Double = MIN_ZOOM_LEVEL, + zoomLevel: Double = MinZoomLevel, mapCenter: GeoPoint = GeoPoint(0.0, 0.0), - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, ): MapView { var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) } - var savedCenter by - rememberSaveable( - stateSaver = - Saver( - save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, - restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) }, - ), - ) { - mutableStateOf(mapCenter) - } + var savedCenter by rememberSaveable(stateSaver = Saver( + save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, + restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) } + )) { mutableStateOf(mapCenter) } val context = LocalContext.current val mapView = remember { @@ -89,19 +71,21 @@ internal fun rememberMapViewWithLifecycle( clipToOutline = true // Required to get online tiles - Configuration.getInstance().userAgentValue = applicationId - setTileSource(tileSource) + Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID isVerticalMapRepetitionEnabled = false // disables map repetition setMultiTouchControls(true) - val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map - setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0) + setScrollableAreaLimitLatitude( // bounds scrollable map + overlayManager.tilesOverlay.bounds.actualNorth, + overlayManager.tilesOverlay.bounds.actualSouth, + 0 + ) // scales the map tiles to the display density of the screen isTilesScaledToDpi = true // sets the minimum zoom level (the furthest out you can zoom) - minZoomLevel = MIN_ZOOM_LEVEL - maxZoomLevel = MAX_ZOOM_LEVEL + minZoomLevel = MinZoomLevel + maxZoomLevel = MaxZoomLevel // Disables default +/- button for zooming - zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) controller.setZoom(savedZoom) controller.setCenter(savedCenter) @@ -109,13 +93,23 @@ internal fun rememberMapViewWithLifecycle( } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + + @Suppress("DEPRECATION") + val wakeLock = + powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock") + + wakeLock.safeAcquire() + val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE -> { + wakeLock.safeRelease() mapView.onPause() } Lifecycle.Event.ON_RESUME -> { + wakeLock.safeAcquire() mapView.onResume() } @@ -130,7 +124,11 @@ internal fun rememberMapViewWithLifecycle( lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } + onDispose { + lifecycle.removeObserver(observer) + wakeLock.safeRelease() + mapView.onDetach() + } } return mapView } diff --git a/app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt b/app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt new file mode 100644 index 000000000..58a5e6da1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt @@ -0,0 +1,148 @@ +package com.geeksville.mesh.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.geeksville.mesh.DeviceMetrics.Companion.currentTime +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.deviceMetrics +import com.geeksville.mesh.environmentMetrics +import com.geeksville.mesh.paxcount +import com.geeksville.mesh.position +import com.geeksville.mesh.telemetry +import com.geeksville.mesh.user +import com.google.protobuf.ByteString +import kotlin.random.Random + +class NodeEntityPreviewParameterProvider : PreviewParameterProvider { + + val mickeyMouse = NodeEntity( + num = 1955, + user = user { + id = "mickeyMouseId" + longName = "Mickey Mouse" + shortName = "MM" + hwModel = MeshProtos.HardwareModel.TBEAM + }, + longName = "Mickey Mouse", + shortName = "MM", + position = position { + latitudeI = 338125110 + longitudeI = -1179189760 + altitude = 138 + satsInView = 4 + }, + latitude = 33.812511, + longitude = -117.918976, + lastHeard = currentTime(), + channel = 0, + snr = 12.5F, + rssi = -42, + deviceTelemetry = telemetry { + deviceMetrics = deviceMetrics { + channelUtilization = 2.4F + airUtilTx = 3.5F + batteryLevel = 85 + voltage = 3.7F + uptimeSeconds = 3600 + } + }, + hopsAway = 0 + ) + + private val minnieMouse = mickeyMouse.copy( + num = Random.nextInt(), + user = user { + longName = "Minnie Mouse" + shortName = "MiMo" + id = "minnieMouseId" + hwModel = MeshProtos.HardwareModel.HELTEC_V3 + }, + longName = "Minnie Mouse", + shortName = "MiMo", + snr = 12.5F, + rssi = -42, + position = position {}, + latitude = 0.0, + longitude = 0.0, + hopsAway = 1 + ) + + private val donaldDuck = NodeEntity( + num = Random.nextInt(), + position = position { + latitudeI = 338052347 + longitudeI = -1179208460 + altitude = 121 + satsInView = 66 + }, + latitude = 33.8052347, + longitude = -117.9208460, + lastHeard = currentTime() - 300, + channel = 0, + snr = 12.5F, + rssi = -42, + deviceTelemetry = telemetry { + deviceMetrics = deviceMetrics { + channelUtilization = 2.4F + airUtilTx = 3.5F + batteryLevel = 85 + voltage = 3.7F + uptimeSeconds = 3600 + } + }, + user = user { + id = "donaldDuckId" + longName = "Donald Duck, the Grand Duck of the Ducks" + shortName = "DoDu" + hwModel = MeshProtos.HardwareModel.HELTEC_V3 + publicKey = ByteString.copyFrom(ByteArray(32) { 1 }) + }, + longName = "Donald Duck, the Grand Duck of the Ducks", + shortName = "DoDu", + environmentTelemetry = telemetry { + environmentMetrics = environmentMetrics { + temperature = 28.0F + relativeHumidity = 50.0F + barometricPressure = 1013.25F + gasResistance = 0.0F + voltage = 3.7F + current = 0.0F + iaq = 100 + } + }, + paxcounter = paxcount { + wifi = 30 + ble = 39 + uptime = 420 + }, + hopsAway = 2 + ) + + private val unknown = donaldDuck.copy( + user = user { + id = "myId" + longName = "Meshtastic myId" + shortName = "myId" + hwModel = MeshProtos.HardwareModel.UNSET + }, + longName = "Meshtastic myId", + shortName = null, + environmentTelemetry = telemetry { + environmentMetrics = environmentMetrics {} + }, + paxcounter = paxcount {}, + ) + + private val almostNothing = NodeEntity( + num = Random.nextInt(), + ) + + override val values: Sequence + get() = sequenceOf( + mickeyMouse, // "this" node + unknown, + almostNothing, + minnieMouse, + donaldDuck + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt b/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt new file mode 100644 index 000000000..75d7c3b15 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt @@ -0,0 +1,114 @@ +package com.geeksville.mesh.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.geeksville.mesh.DeviceMetrics +import com.geeksville.mesh.DeviceMetrics.Companion.currentTime +import com.geeksville.mesh.EnvironmentMetrics +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.Position +import kotlin.random.Random + + +class NodeInfoPreviewParameterProvider: PreviewParameterProvider { + + val mickeyMouse = NodeInfo( + num = 1955, + position = Position( + latitude = 33.812511, + longitude = -117.918976, + altitude = 138, + satellitesInView = 4, + ), + lastHeard = currentTime(), + channel = 0, + snr = 12.5F, + rssi = -42, + deviceMetrics = DeviceMetrics( + channelUtilization = 2.4F, + airUtilTx = 3.5F, + batteryLevel = 85, + voltage = 3.7F, + uptimeSeconds = 3600, + ), + user = MeshUser( + longName = "Micky Mouse", + shortName = "MM", + id = "mickeyMouseId", + hwModel = MeshProtos.HardwareModel.TBEAM, + role = 0, + ), + hopsAway = 0 + ) + + private val minnieMouse = mickeyMouse.copy( + num = Random.nextInt(), + user = MeshUser( + longName = "Minnie Mouse", + shortName = "MiMo", + id = "minnieMouseId", + hwModel = MeshProtos.HardwareModel.HELTEC_V3 + ), + snr = 12.5F, + rssi = -42, + position = null, + hopsAway = 1 + ) + + private val donaldDuck = NodeInfo( + num = Random.nextInt(), + position = Position( + latitude = 33.80523471893125, + longitude = -117.92084605996297, + altitude = 121, + satellitesInView = 66, + ), + lastHeard = currentTime() - 300, + channel = 0, + snr = 12.5F, + rssi = -42, + deviceMetrics = DeviceMetrics( + channelUtilization = 2.4F, + airUtilTx = 3.5F, + batteryLevel = 85, + voltage = 3.7F, + uptimeSeconds = 3600, + ), + user = MeshUser( + longName = "Donald Duck, the Grand Duck of the Ducks", + shortName = "DoDu", + id = "donaldDuckId", + hwModel = MeshProtos.HardwareModel.HELTEC_V3, + ), + environmentMetrics = EnvironmentMetrics( + temperature = 28.0F, + relativeHumidity = 50.0F, + barometricPressure = 1013.25F, + gasResistance = 0.0F, + voltage = 3.7F, + current = 0.0F, + iaq = 100, + ), + hopsAway = 2 + ) + + private val unknown = donaldDuck.copy( + user = null, + environmentMetrics = null + ) + + private val almostNothing = NodeInfo( + num = Random.nextInt(), + ) + + override val values: Sequence + get() = sequenceOf( + mickeyMouse, // "this" node + unknown, + almostNothing, + minnieMouse, + donaldDuck + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt new file mode 100644 index 000000000..9da963e12 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt @@ -0,0 +1,23 @@ +package com.geeksville.mesh.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) + +val LightGray = Color(0xFFFAFAFA) +val LightSkyBlue = Color(0x99A6D1E6) +val LightBlue = Color(0xFFA6D1E6) +val SkyBlue = Color(0xFF57AEFF) +val LightPink = Color(0xFFFFE6E6) +val LightGreen = Color(0xFFCFE8A9) +val LightRed = Color(0xFFFFB3B3) + +val MeshtasticGreen = Color(0xFF67EA94) +val AlmostWhite = Color(0xB3FFFFFF) +val AlmostBlack = Color(0x8A000000) + +val HyperlinkBlue = Color(0xFF43C3B0) +val Orange = Color(255, 153, 0) \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt new file mode 100644 index 000000000..99ce8e696 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Shape.kt @@ -0,0 +1,11 @@ +package com.geeksville.mesh.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt new file mode 100644 index 000000000..ba310667d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package com.geeksville.mesh.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable + +private val DarkColorPalette = darkColors( + primary = MeshtasticGreen, + primaryVariant = Purple700, + secondary = Teal200, + surface = AlmostBlack, + onSurface = AlmostWhite +) + +private val LightColorPalette = lightColors( + primary = MeshtasticGreen, + primaryVariant = LightSkyBlue, + secondary = Teal200, + surface = AlmostWhite, + onSurface = AlmostBlack + + /* Other default colors to override + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.Black, + onSurface = Color.Black, + */ +) + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt new file mode 100644 index 000000000..91432aa78 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Type.kt @@ -0,0 +1,41 @@ +package com.geeksville.mesh.ui.theme + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + h3 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 24.sp + ), + h4 = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ), + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + body2 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) diff --git a/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt new file mode 100644 index 000000000..07b2f361d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/CompatExtensions.kt @@ -0,0 +1,43 @@ +package com.geeksville.mesh.util + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Parcel +import android.os.Parcelable +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import androidx.core.os.ParcelCompat + +object PendingIntentCompat { + val FLAG_IMMUTABLE = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } +} + +inline fun Parcel.readParcelableCompat(loader: ClassLoader?): T? = + ParcelCompat.readParcelable(this, loader, T::class.java) + +inline fun Intent.getParcelableExtraCompat(key: String?): T? = + IntentCompat.getParcelableExtra(this, key, T::class.java) + +fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") getPackageInfo(packageName, flags) + } + +fun Context.registerReceiverCompat( + receiver: BroadcastReceiver, + filter: IntentFilter, + flag: Int = ContextCompat.RECEIVER_EXPORTED, +) { + ContextCompat.registerReceiver(this, receiver, filter, flag) +} diff --git a/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt b/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt new file mode 100644 index 000000000..d1d2a3ae2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/CustomRecentEmojiProvider.kt @@ -0,0 +1,48 @@ +package com.geeksville.mesh.util + +import android.content.Context +import androidx.emoji2.emojipicker.RecentEmojiAsyncProvider +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** + * Define a custom recent emoji provider which shows most frequently used emoji + */ +class CustomRecentEmojiProvider( + context: Context +) : RecentEmojiAsyncProvider { + + private val sharedPreferences = + context.getSharedPreferences(RECENT_EMOJI_LIST_FILE_NAME, Context.MODE_PRIVATE) + + private val emoji2Frequency: MutableMap by lazy { + sharedPreferences.getString(PREF_KEY_CUSTOM_EMOJI_FREQ, null)?.split(SPLIT_CHAR) + ?.associate { entry -> + entry.split(KEY_VALUE_DELIMITER, limit = 2).takeIf { it.size == 2 } + ?.let { it[0] to it[1].toInt() } ?: ("" to 0) + }?.toMutableMap() ?: mutableMapOf() + } + + override fun getRecentEmojiListAsync(): ListenableFuture> = + Futures.immediateFuture(emoji2Frequency.toList().sortedByDescending { it.second } + .map { it.first }) + + override fun recordSelection(emoji: String) { + emoji2Frequency[emoji] = (emoji2Frequency[emoji] ?: 0) + 1 + saveToPreferences() + } + + private fun saveToPreferences() { + sharedPreferences + .edit() + .putString(PREF_KEY_CUSTOM_EMOJI_FREQ, emoji2Frequency.entries.joinToString(SPLIT_CHAR)) + .apply() + } + + companion object { + private const val PREF_KEY_CUSTOM_EMOJI_FREQ = "pref_key_custom_emoji_freq" + private const val RECENT_EMOJI_LIST_FILE_NAME = "org.geeksville.emoji.prefs" + private const val SPLIT_CHAR = "," + private const val KEY_VALUE_DELIMITER = "=" + } +} diff --git a/app/src/main/java/com/geeksville/mesh/util/DistanceExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/DistanceExtensions.kt new file mode 100644 index 000000000..4455cada4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/DistanceExtensions.kt @@ -0,0 +1,74 @@ +package com.geeksville.mesh.util + +import android.icu.util.LocaleData +import android.icu.util.ULocale +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import java.util.Locale + +enum class DistanceUnit( + val symbol: String, + val multiplier: Float, + val system: Int +) { + METER("m", multiplier = 1F, DisplayConfig.DisplayUnits.METRIC_VALUE), + KILOMETER("km", multiplier = 0.001F, DisplayConfig.DisplayUnits.METRIC_VALUE), + FOOT("ft", multiplier = 3.28084F, DisplayConfig.DisplayUnits.IMPERIAL_VALUE), + MILE("mi", multiplier = 0.000621371F, DisplayConfig.DisplayUnits.IMPERIAL_VALUE), + ; + + companion object { + fun getFromLocale(locale: Locale): DisplayConfig.DisplayUnits { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) { + LocaleData.MeasurementSystem.SI -> DisplayConfig.DisplayUnits.METRIC + else -> DisplayConfig.DisplayUnits.IMPERIAL + } + } else { + when (locale.country.uppercase(locale)) { + "US", "LR", "MM", "GB" -> DisplayConfig.DisplayUnits.IMPERIAL + else -> DisplayConfig.DisplayUnits.METRIC + } + } + } + } +} + +fun Int.metersIn(unit: DistanceUnit): Float { + return this * unit.multiplier +} + +fun Int.metersIn(system: DisplayConfig.DisplayUnits): Float { + val unit = when (system.number) { + DisplayConfig.DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT + else -> DistanceUnit.METER + } + return this.metersIn(unit) +} + +fun Float.toString(unit: DistanceUnit): String { + return if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) { + "%.0f %s" + } else { + "%.1f %s" + }.format(this, unit.symbol) +} + +fun Float.toString(system: DisplayConfig.DisplayUnits): String { + val unit = when (system.number) { + DisplayConfig.DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT + else -> DistanceUnit.METER + } + return this.toString(unit) +} + +private const val KILOMETER_THRESHOLD = 1000 +private const val MILE_THRESHOLD = 1609 +fun Int.toDistanceString(system: DisplayConfig.DisplayUnits): String { + val unit = if (system.number == DisplayConfig.DisplayUnits.METRIC_VALUE) { + if (this < KILOMETER_THRESHOLD) DistanceUnit.METER else DistanceUnit.KILOMETER + } else { + if (this < MILE_THRESHOLD) DistanceUnit.FOOT else DistanceUnit.MILE + } + val valueInUnit = this * unit.multiplier + return valueInUnit.toString(unit) +} diff --git a/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt b/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt new file mode 100644 index 000000000..690574a9e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/Exceptions.kt @@ -0,0 +1,79 @@ +package com.geeksville.mesh.util + +import android.os.RemoteException +import android.util.Log +import android.view.View +import com.geeksville.mesh.android.Logging +import com.google.android.material.snackbar.Snackbar + + +object Exceptions : Logging { + /// Set in Application.onCreate + var reporter: ((Throwable, String?, String?) -> Unit)? = null + + /** + * Report an exception to our analytics provider (if installed - otherwise just log) + * + * After reporting return + */ + fun report(exception: Throwable, tag: String? = null, message: String? = null) { + errormsg( + "Exceptions.report: $tag $message", + exception + ) // print the message to the log _before_ telling the crash reporter + reporter?.let { r -> + r(exception, tag, message) + } + } +} + +/** + * This wraps (and discards) exceptions, but first it reports them to our bug tracking system and prints + * a message to the log. + */ +fun exceptionReporter(inner: () -> Unit) { + try { + inner() + } catch (ex: Throwable) { + // DO NOT THROW users expect we have fully handled/discarded the exception + Exceptions.report(ex, "exceptionReporter", "Uncaught Exception") + } +} + +/** + * If an exception occurs, show the message in a snackbar and continue + */ +fun exceptionToSnackbar(view: View, inner: () -> Unit) { + try { + inner() + } catch (ex: Throwable) { + Snackbar.make(view, ex.message ?: "An exception occurred", Snackbar.LENGTH_LONG).show() + } +} + + +/** + * This wraps (and discards) exceptions, but it does output a log message + */ +fun ignoreException(silent: Boolean = false, inner: () -> Unit) { + try { + inner() + } catch (ex: Throwable) { + // DO NOT THROW users expect we have fully handled/discarded the exception + if(!silent) + Exceptions.errormsg("ignoring exception", ex) + } +} + +/// Convert any exceptions in this service call into a RemoteException that the client can +/// then handle +fun toRemoteExceptions(inner: () -> T): T = try { + inner() +} catch (ex: Throwable) { + Log.e("toRemoteExceptions", "Uncaught exception, returning to remote client", ex) + when(ex) { // don't double wrap remote exceptions + is RemoteException -> throw ex + else -> throw RemoteException(ex.message) + } +} + diff --git a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt new file mode 100644 index 000000000..997b27de4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt @@ -0,0 +1,64 @@ +package com.geeksville.mesh.util + +import android.widget.EditText +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.ConfigProtos + +/** + * When printing strings to logs sometimes we want to print useful debugging information about users + * or positions. But we don't want to leak things like usernames or locations. So this function + * if given a string, will return a string which is a maximum of three characters long, taken from the tail + * of the string. Which should effectively hide real usernames and locations, + * but still let us see if values were zero, empty or different. + */ +val Any?.anonymize: String + get() = this.anonymize() + +/** + * A version of anonymize that allows passing in a custom minimum length + */ +fun Any?.anonymize(maxLen: Int = 3) = + if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" + +// A toString that makes sure all newlines are removed (for nice logging). +fun Any.toOneLineString() = this.toString().replace('\n', ' ') + +fun ConfigProtos.Config.toOneLineString(): String { + val redactedFields = """(wifi_psk:|public_key:|private_key:|admin_key:)\s*".*""" + return this.toString() + .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } + .replace('\n', ' ') +} + +// Return a one line string version of an object (but if a release build, just say 'might be PII) +fun Any.toPIIString() = + if (!BuildConfig.DEBUG) { + "" + } else { + this.toOneLineString() + } + +fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } + +fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMillis()): String { + val currentTime = (currentTimeMillis / 1000).toInt() + val diffMin = (currentTime - lastSeenUnix) / 60 + return when { + diffMin < 1 -> "now" + diffMin < 60 -> diffMin.toString() + " min" + diffMin < 2880 -> (diffMin / 60).toString() + " h" + diffMin < 1440000 -> (diffMin / (60 * 24)).toString() + " d" + else -> "?" + } +} + +// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() }) +fun EditText.onEditorAction(actionId: Int, func: () -> Unit) { + setOnEditorActionListener { _, receivedActionId, _ -> + + if (actionId == receivedActionId) { + func() + } + true + } +} diff --git a/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt b/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt new file mode 100644 index 000000000..8157b67a4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/LanguageUtils.kt @@ -0,0 +1,70 @@ +package com.geeksville.mesh.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import androidx.core.os.LocaleListCompat +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.R +import org.xmlpull.v1.XmlPullParser +import java.util.Locale + +object LanguageUtils : Logging { + + const val SYSTEM_DEFAULT = "zz" + const val SYSTEM_MANAGED = "appcompat" + + fun getLocale(): String { + return AppCompatDelegate.getApplicationLocales().toLanguageTags().ifEmpty { SYSTEM_DEFAULT } + } + + fun setLocale(lang: String) { + AppCompatDelegate.setApplicationLocales( + if (lang == SYSTEM_DEFAULT) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(lang) + } + ) + } + + fun migrateLanguagePrefs(prefs: SharedPreferences) { + val currentLang = prefs.getString("lang", SYSTEM_DEFAULT) ?: SYSTEM_DEFAULT + debug("Migrating in-app language prefs: $currentLang") + prefs.edit { putString("lang", SYSTEM_MANAGED) } + setLocale(currentLang) + } + + /** + * Build a list from locales_config.xml + * of native language names paired to its Locale tag (ex: "English", "en") + */ + fun getLanguageTags(context: Context): Map { + val languageTags = mutableListOf(SYSTEM_DEFAULT) + try { + context.resources.getXml(R.xml.locales_config).use { + while (it.eventType != XmlPullParser.END_DOCUMENT) { + if (it.eventType == XmlPullParser.START_TAG && it.name == "locale") { + languageTags += it.getAttributeValue(0) + } + it.next() + } + } + } catch (e: Exception) { + errormsg("Error parsing locale_config.xml ${e.message}") + } + return languageTags.associateBy { tag -> + val loc = Locale(tag) + when (tag) { + SYSTEM_DEFAULT -> context.getString(R.string.preferences_system_default) + "fr-HT" -> context.getString(R.string.fr_HT) + "pt-BR" -> context.getString(R.string.pt_BR) + "zh-CN" -> context.getString(R.string.zh_CN) + "zh-TW" -> context.getString(R.string.zh_TW) + else -> loc.getDisplayLanguage(loc) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(loc) else it.toString() } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt new file mode 100644 index 000000000..613bb8b65 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/LocationUtils.kt @@ -0,0 +1,310 @@ +package com.geeksville.mesh.util + +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.Position +import mil.nga.grid.features.Point +import mil.nga.mgrs.MGRS +import mil.nga.mgrs.utm.UTM +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.PI + +/******************************************************************************* + * Revive some of my old Gaggle source code... + * + * GNU Public License, version 2 + * All other distribution of Gaggle must conform to the terms of the GNU Public License, version 2. The full + * text of this license is included in the Gaggle source, see assets/manual/gpl-2.0.txt. + ******************************************************************************/ + +object GPSFormat { + fun DEC(p: Position): String { + return String.format("%.5f %.5f", p.latitude, p.longitude).replace(",", ".") + } + + fun DMS(p: Position): String { + val lat = degreesToDMS(p.latitude, true) + val lon = degreesToDMS(p.longitude, false) + fun string(a: Array) = String.format("%s°%s'%.5s\"%s", a[0], a[1], a[2], a[3]) + return string(lat) + " " + string(lon) + } + + fun UTM(p: Position): String { + val UTM = UTM.from(Point.point(p.longitude, p.latitude)) + return String.format( + "%s%s %.6s %.7s", + UTM.zone, + UTM.toMGRS().band, + UTM.easting, + UTM.northing + ) + } + + fun MGRS(p: Position): String { + val MGRS = MGRS.from(Point.point(p.longitude, p.latitude)) + return String.format( + "%s%s %s%s %05d %05d", + MGRS.zone, + MGRS.band, + MGRS.column, + MGRS.row, + MGRS.easting, + MGRS.northing + ) + } + + fun toDEC(latitude: Double, longitude: Double): String { + return "%.5f %.5f".format(latitude, longitude).replace(",", ".") + } + + fun toDMS(latitude: Double, longitude: Double): String { + val lat = degreesToDMS(latitude, true) + val lon = degreesToDMS(longitude, false) + fun string(a: Array) = "%s°%s'%.5s\"%s".format(a[0], a[1], a[2], a[3]) + return string(lat) + " " + string(lon) + } + + fun toUTM(latitude: Double, longitude: Double): String { + val UTM = UTM.from(Point.point(longitude, latitude)) + return "%s%s %.6s %.7s".format(UTM.zone, UTM.toMGRS().band, UTM.easting, UTM.northing) + } + + fun toMGRS(latitude: Double, longitude: Double): String { + val MGRS = MGRS.from(Point.point(longitude, latitude)) + return "%s%s %s%s %05d %05d".format( + MGRS.zone, + MGRS.band, + MGRS.column, + MGRS.row, + MGRS.easting, + MGRS.northing + ) + } +} + +/** + * Format as degrees, minutes, secs + * + * @param degIn + * @param isLatitude + * @return a string like 120deg + */ +fun degreesToDMS( + _degIn: Double, + isLatitude: Boolean +): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn.toInt() + val minutes = 60 * (degIn - degOut) + val minwhole = minutes.toInt() + val seconds = (minutes - minwhole) * 60 + return arrayOf( + degOut.toString(), minwhole.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +fun degreesToDM(_degIn: Double, isLatitude: Boolean): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn.toInt() + val minutes = 60 * (degIn - degOut) + val seconds = 0 + return arrayOf( + degOut.toString(), minutes.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +fun degreesToD(_degIn: Double, isLatitude: Boolean): Array { + var degIn = _degIn + val isPos = degIn >= 0 + val dirLetter = + if (isLatitude) if (isPos) 'N' else 'S' else if (isPos) 'E' else 'W' + degIn = abs(degIn) + val degOut = degIn + val minutes = 0 + val seconds = 0 + return arrayOf( + degOut.toString(), minutes.toString(), + seconds.toString(), + dirLetter.toString() + ) +} + +/** + * A not super efficent mapping from a starting lat/long + a distance at a + * certain direction + * + * @param lat + * @param longitude + * @param distMeters + * @param theta + * in radians, 0 == north + * @return an array with lat and long + */ +fun addDistance( + lat: Double, + longitude: Double, + distMeters: Double, + theta: Double +): DoubleArray { + val dx = distMeters * sin(theta) // theta measured clockwise + // from due north + val dy = distMeters * cos(theta) // dx, dy same units as R + val dLong = dx / (111320 * cos(lat)) // dx, dy in meters + val dLat = dy / 110540 // result in degrees long/lat + return doubleArrayOf(lat + dLat, longitude + dLong) +} + +/** + * @return distance in meters along the surface of the earth (ish) + */ +fun latLongToMeter( + lat_a: Double, + lng_a: Double, + lat_b: Double, + lng_b: Double +): Double { + val pk = (180 / PI) + val a1 = lat_a / pk + val a2 = lng_a / pk + val b1 = lat_b / pk + val b2 = lng_b / pk + val t1 = cos(a1) * cos(a2) * cos(b1) * cos(b2) + val t2 = cos(a1) * sin(a2) * cos(b1) * sin(b2) + val t3 = sin(a1) * sin(b1) + var tt = acos(t1 + t2 + t3) + if (java.lang.Double.isNaN(tt)) tt = 0.0 // Must have been the same point? + return 6366000 * tt +} + +// Same as above, but takes Mesh Position proto. +fun positionToMeter(a: MeshProtos.Position, b: MeshProtos.Position): Double { + return latLongToMeter( + a.latitudeI * 1e-7, + a.longitudeI * 1e-7, + b.latitudeI * 1e-7, + b.longitudeI * 1e-7 + ) +} + +/** + * Convert degrees/mins/secs to a single double + * + * @param degrees + * @param minutes + * @param seconds + * @param isPostive + * @return + */ +fun DMSToDegrees( + degrees: Int, + minutes: Int, + seconds: Float, + isPostive: Boolean +): Double { + return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) +} + +fun DMSToDegrees( + degrees: Double, + minutes: Double, + seconds: Double, + isPostive: Boolean +): Double { + return (if (isPostive) 1 else -1) * (degrees + minutes / 60.0 + seconds / 3600.0) +} + +/** + * Computes the bearing in degrees between two points on Earth. + * + * @param lat1 + * Latitude of the first point + * @param lon1 + * Longitude of the first point + * @param lat2 + * Latitude of the second point + * @param lon2 + * Longitude of the second point + * @return Bearing between the two points in degrees. A value of 0 means due + * north. + */ +fun bearing( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double +): Double { + val lat1Rad = Math.toRadians(lat1) + val lat2Rad = Math.toRadians(lat2) + val deltaLonRad = Math.toRadians(lon2 - lon1) + val y = sin(deltaLonRad) * cos(lat2Rad) + val x = cos(lat1Rad) * sin(lat2Rad) - (sin(lat1Rad) * cos(lat2Rad) * cos(deltaLonRad)) + return radToBearing(atan2(y, x)) +} + +/** + * Converts an angle in radians to degrees + */ +fun radToBearing(rad: Double): Double { + return (Math.toDegrees(rad) + 360) % 360 +} + +/** + * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. + * @return The zoom level as a Double value. + */ +fun BoundingBox.requiredZoomLevel(): Double { + val topLeft = GeoPoint(this.latNorth, this.lonWest) + val bottomRight = GeoPoint(this.latSouth, this.lonEast) + val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) + val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) + val requiredLatZoom = log2(360.0 / (latLonHeight / 111320)) + val requiredLonZoom = log2(360.0 / (latLonWidth / 111320)) + return maxOf(requiredLatZoom, requiredLonZoom) * 0.8 +} + +/** + * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. + * @return A new [BoundingBox] with added [zoomFactor]. Example: + * ``` + * // Setting the zoom level directly using setZoom() + * map.setZoom(14.0) + * val boundingBoxZoom14 = map.boundingBox + * + * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) + * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) + * ``` + */ +fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { + val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) + val latDiff = latNorth - latSouth + val lonDiff = lonEast - lonWest + + val newLatDiff = latDiff / (2.0.pow(zoomFactor)) + val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) + + return BoundingBox( + center.latitude + newLatDiff / 2, + center.longitude + newLonDiff / 2, + center.latitude - newLatDiff / 2, + center.longitude - newLonDiff / 2 + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt b/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt new file mode 100644 index 000000000..15bf8b250 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/MapViewExtensions.kt @@ -0,0 +1,131 @@ +package com.geeksville.mesh.util + +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.R +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.CopyrightOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline +import org.osmdroid.views.overlay.ScaleBarOverlay +import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList +import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 + +/** + * Adds copyright to map depending on what source is showing + */ +fun MapView.addCopyright() { + if (overlays.none { it is CopyrightOverlay }) { + val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return + val copyrightOverlay = CopyrightOverlay(context) + copyrightOverlay.setCopyrightNotice(copyrightNotice) + overlays.add(copyrightOverlay) + } +} + +/** + * Create LatLong Grid line overlay + * @param enabled: turn on/off gridlines + */ +fun MapView.createLatLongGrid(enabled: Boolean) { + val latLongGridOverlay = LatLonGridlineOverlay2() + latLongGridOverlay.isEnabled = enabled + if (latLongGridOverlay.isEnabled) { + val textPaint = Paint().apply { + textSize = 40f + color = Color.GRAY + isAntiAlias = true + isFakeBoldText = true + textAlign = Paint.Align.CENTER + } + latLongGridOverlay.textPaint = textPaint + latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT) + latLongGridOverlay.setLineWidth(3.0f) + latLongGridOverlay.setLineColor(Color.GRAY) + overlays.add(latLongGridOverlay) + } +} + +fun MapView.addScaleBarOverlay(density: Density) { + if (overlays.none { it is ScaleBarOverlay }) { + val scaleBarOverlay = ScaleBarOverlay(this).apply { + setAlignBottom(true) + with(density) { + setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt()) + setTextSize(12.sp.toPx()) + } + textPaint.apply { + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD + } + } + overlays.add(scaleBarOverlay) + } +} + +fun MapView.addPolyline( + density: Density, + geoPoints: List, + onClick: () -> Unit +): Polyline { + val polyline = Polyline(this).apply { + val borderPaint = Paint().apply { + color = Color.BLACK + isAntiAlias = true + strokeWidth = with(density) { 10.dp.toPx() } + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) + } + outlinePaintLists.add(MonochromaticPaintList(borderPaint)) + val fillPaint = Paint().apply { + color = Color.WHITE + isAntiAlias = true + strokeWidth = with(density) { 6.dp.toPx() } + style = Paint.Style.FILL_AND_STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) + } + outlinePaintLists.add(MonochromaticPaintList(fillPaint)) + setPoints(geoPoints) + setOnClickListener { _, _, _ -> + onClick() + true + } + } + overlays.add(polyline) + + return polyline +} + +fun MapView.addPositionMarkers( + positions: List, + onClick: () -> Unit +): List { + val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation_24) + val markers = positions.map { + Marker(this).apply { + icon = navIcon + rotation = (it.groundTrack * 1e-5).toFloat() + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + position = GeoPoint(it.latitudeI * 1e-7, it.longitudeI * 1e-7) + setOnMarkerClickListener { _, _ -> + onClick() + true + } + } + } + overlays.addAll(markers) + + return markers +} diff --git a/app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt b/app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt new file mode 100644 index 000000000..edeea8306 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/util/SqlTileWriterExt.kt @@ -0,0 +1,81 @@ +package com.geeksville.mesh.util + +import android.database.Cursor +import org.osmdroid.tileprovider.modules.DatabaseFileArchive +import org.osmdroid.tileprovider.modules.SqlTileWriter + + +/** + * Extended the sqlite tile writer to have some additional query functions. A this point + * it's unclear if there is a need to put these with the osmdroid-android library, thus they were + * put here as more of an example. + * + * + * created on 12/21/2016. + * + * @author Alex O'Ree + * @since 5.6.2 + */ +class SqlTileWriterExt() : SqlTileWriter() { + fun select(rows: Int, offset: Int): Cursor? { + return this.db?.rawQuery( + "select " + DatabaseFileArchive.COLUMN_KEY + "," + COLUMN_EXPIRES + "," + DatabaseFileArchive.COLUMN_PROVIDER + " from " + DatabaseFileArchive.TABLE + " limit ? offset ?", + arrayOf(rows.toString() + "", offset.toString() + "") + ) + } + + /** + * gets all the tiles sources that we have tiles for in the cache database and their counts + * + * @return + */ + val sources: List + get() { + val db = db + val ret: MutableList = ArrayList() + if (db == null) { + return ret + } + var cur: Cursor? = null + try { + cur = db.rawQuery( + "select " + + DatabaseFileArchive.COLUMN_PROVIDER + + ",count(*) " + + ",min(length(" + DatabaseFileArchive.COLUMN_TILE + ")) " + + ",max(length(" + DatabaseFileArchive.COLUMN_TILE + ")) " + + ",sum(length(" + DatabaseFileArchive.COLUMN_TILE + ")) " + + "from " + DatabaseFileArchive.TABLE + " " + + "group by " + DatabaseFileArchive.COLUMN_PROVIDER, null + ) + while (cur.moveToNext()) { + val c = SourceCount() + c.source = cur.getString(0) + c.rowCount = cur.getLong(1) + c.sizeMin = cur.getLong(2) + c.sizeMax = cur.getLong(3) + c.sizeTotal = cur.getLong(4) + c.sizeAvg = c.sizeTotal / c.rowCount + ret.add(c) + } + } catch (e: Exception) { + catchException(e) + } finally { + cur?.close() + } + return ret + } + val rowCountExpired: Long + get() = getRowCount( + "$COLUMN_EXPIRES= dend) { + char c = dest.charAt(i); + destByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3); + } + } + int keepBytes = mMaxBytes - destByteCount; + if (keepBytes <= 0) { + return ""; + } else if (keepBytes >= srcByteCount) { + return null; // use original dest string + } else { + // find end position of largest sequence that fits in keepBytes + for (int i = start; i < end; i++) { + char c = source.charAt(i); + keepBytes -= (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3); + if (keepBytes < 0) { + return source.subSequence(start, i); + } + } + // If the entire substring fits, we should have returned null + // above, so this line should not be reached. If for some + // reason it is, return null to use the original dest string. + return null; + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt deleted file mode 100644 index 628865010..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app - -import android.app.PendingIntent -import android.app.TaskStackBuilder -import android.content.Intent -import android.graphics.Color -import android.hardware.usb.UsbManager -import android.net.Uri -import android.nfc.NdefMessage -import android.nfc.NfcAdapter -import android.os.Build -import android.os.Bundle -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.compose.ReportDrawnWhen -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.core.content.IntentCompat -import androidx.core.net.toUri -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope -import co.touchlab.kermit.Logger -import coil3.ImageLoader -import coil3.compose.setSingletonImageLoaderFactory -import com.eygraber.uri.toKmpUri -import kotlinx.coroutines.launch -import org.koin.android.ext.android.get -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import org.meshtastic.app.intro.AnalyticsIntro -import org.meshtastic.app.map.getMapViewProvider -import org.meshtastic.app.node.component.InlineMap -import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets -import org.meshtastic.app.ui.MainScreen -import org.meshtastic.core.barcode.rememberBarcodeScanner -import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI -import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.nfc.NfcScannerEffect -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.channel_invalid -import org.meshtastic.core.service.MeshServiceClient -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.core.ui.theme.MODE_DYNAMIC -import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider -import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider -import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported -import org.meshtastic.core.ui.util.LocalInlineMapProvider -import org.meshtastic.core.ui.util.LocalMapMainScreenProvider -import org.meshtastic.core.ui.util.LocalMapViewProvider -import org.meshtastic.core.ui.util.LocalNfcScannerProvider -import org.meshtastic.core.ui.util.LocalNfcScannerSupported -import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider -import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider -import org.meshtastic.core.ui.util.showToast -import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.intro.AppIntroductionScreen -import org.meshtastic.feature.intro.IntroViewModel -import org.meshtastic.feature.map.MapScreen -import org.meshtastic.feature.map.SharedMapViewModel -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.feature.node.metrics.MetricsViewModel -import org.meshtastic.feature.node.metrics.TracerouteMapScreen - -class MainActivity : ComponentActivity() { - private val model: UIViewModel by viewModel() - - private val usbRepository: UsbRepository by inject() - - /** - * Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers - * itself as a LifecycleObserver in its init block. - */ - internal val meshServiceClient: MeshServiceClient by inject { parametersOf(this) } - - override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() - - // Eagerly evaluate lazy Koin dependency so it registers its LifecycleObserver - meshServiceClient.hashCode() - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - // Explicitly set the cutout mode to ALWAYS for Android 15+ to satisfy Play Console recommendations. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - window.attributes.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS - } - - // Ensure the navigation bar remains seamless on modern Android versions - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.isNavigationBarContrastEnforced = false - } - - setContent { - // Bridge Koin-provided ImageLoader (with flavor-specific HttpClient, SVG, debug logger) - // to Coil's singleton so all AsyncImage composables use the custom configuration. - setSingletonImageLoaderFactory { get() } - - val theme by model.theme.collectAsStateWithLifecycle() - val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle() - val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue) - val dynamic = theme == MODE_DYNAMIC - val dark = - when (theme) { - AppCompatDelegate.MODE_NIGHT_YES -> true - AppCompatDelegate.MODE_NIGHT_NO -> false - else -> isSystemInDarkTheme() - } - - // Update system bar style when theme changes - androidx.compose.runtime.SideEffect { - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark }, - navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark }, - ) - } - - AppCompositionLocals { - AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) { - val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle() - - // Signal to the system that the initial UI is "fully drawn" - // once we've decided whether to show the intro or the main screen. - ReportDrawnWhen { true } - - if (appIntroCompleted) { - MainScreen() - } else { - val introViewModel = koinViewModel() - AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel) - } - } - } - } - - // Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent - addOnNewIntentListener { intent -> handleIntent(intent) } - - handleIntent(intent) - } - - override fun onResume() { - super.onResume() - // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is - // resumed while a USB device is already attached (e.g. process restart, returning - // from another app), the manifest-declared attach intent may have already fired - // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects - // reality without requiring the user to physically replug. - usbRepository.refreshState() - } - - @Composable - private fun AppCompositionLocals(content: @Composable () -> Unit) { - CompositionLocalProvider( - LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) }, - LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) }, - LocalBarcodeScannerSupported provides true, - LocalNfcScannerSupported provides true, - LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, - LocalMapViewProvider provides getMapViewProvider(), - LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, - LocalNodeTrackMapProvider provides - { destNum, positions, modifier, selectedPositionTime, onPositionSelected -> - org.meshtastic.app.map.node.NodeTrackMap( - destNum, - positions, - modifier, - selectedPositionTime, - onPositionSelected, - ) - }, - LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), - LocalTracerouteMapProvider provides - { overlay, nodePositions, onMappableCountChanged, modifier -> - org.meshtastic.app.map.traceroute.TracerouteMap( - tracerouteOverlay = overlay, - tracerouteNodePositions = nodePositions, - onMappableCountChanged = onMappableCountChanged, - modifier = modifier, - ) - }, - LocalNodeMapScreenProvider provides - { destNum, onNavigateUp -> - val vm = koinViewModel() - vm.setDestNum(destNum) - org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) - }, - LocalTracerouteMapScreenProvider provides - { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = koinViewModel { parametersOf(destNum) } - metricsViewModel.setNodeId(destNum) - - TracerouteMapScreen( - metricsViewModel = metricsViewModel, - requestId = requestId, - logUuid = logUuid, - onNavigateUp = onNavigateUp, - ) - }, - LocalMapMainScreenProvider provides - { onClickNodeChip, navigateToNodeDetails, waypointId -> - val viewModel = koinViewModel() - MapScreen( - viewModel = viewModel, - onClickNodeChip = onClickNodeChip, - navigateToNodeDetails = navigateToNodeDetails, - waypointId = waypointId, - ) - }, - content = content, - ) - } - - @Suppress("NestedBlockDepth") - private fun handleIntent(intent: Intent) { - val appLinkAction = intent.action - val appLinkData: Uri? = intent.data - - when (appLinkAction) { - Intent.ACTION_VIEW -> { - appLinkData?.let { handleMeshtasticUri(it) } - } - - NfcAdapter.ACTION_NDEF_DISCOVERED -> { - val rawMessages = - IntentCompat.getParcelableArrayExtra( - intent, - NfcAdapter.EXTRA_NDEF_MESSAGES, - NdefMessage::class.java, - ) - if (rawMessages != null) { - for (rawMsg in rawMessages) { - val msg = rawMsg as NdefMessage - for (record in msg.records) { - record.toUri()?.let { handleMeshtasticUri(it) } - } - } - } - } - - UsbManager.ACTION_USB_DEVICE_ATTACHED -> { - Logger.d { "USB device attached" } - // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared - // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository - // never sees this event. Forward it explicitly so the serialDevices StateFlow - // refreshes and the device shows up in the Connect → Serial tab. - usbRepository.refreshState() - showSettingsPage() - } - - Intent.ACTION_MAIN -> {} - - Intent.ACTION_SEND -> { - val text = intent.getStringExtra(Intent.EXTRA_TEXT) - if (text != null) { - createShareIntent(text).send() - } - } - - else -> { - Logger.w { "Unexpected action $appLinkAction" } - } - } - } - - private fun handleMeshtasticUri(uri: Uri) { - Logger.d { "Handling Meshtastic URI: $uri" } - - model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } } - } - - private fun createShareIntent(message: String): PendingIntent { - val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message" - val startActivityIntent = - Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - - val resultPendingIntent: PendingIntent? = - TaskStackBuilder.create(this).run { - addNextIntentWithParentStack(startActivityIntent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) - } - return resultPendingIntent!! - } - - private fun createSettingsIntent(): PendingIntent { - val deepLink = "$DEEP_LINK_BASE_URI/connections" - val startActivityIntent = - Intent(Intent.ACTION_VIEW, deepLink.toUri(), this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - - val resultPendingIntent: PendingIntent? = - TaskStackBuilder.create(this).run { - addNextIntentWithParentStack(startActivityIntent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE) - } - return resultPendingIntent!! - } - - private fun showSettingsPage() { - createSettingsIntent().send() - } -} diff --git a/app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt deleted file mode 100644 index 80cc15dde..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app - -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Module - -@Module -@ComponentScan("org.meshtastic.app") -class MainKoinModule diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt deleted file mode 100644 index 9228b6874..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app - -import android.app.Application -import android.appwidget.AppWidgetProviderInfo -import android.os.Build -import androidx.collection.intSetOf -import androidx.glance.appwidget.GlanceAppWidgetManager -import androidx.work.Configuration -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import org.koin.android.ext.android.get -import org.koin.android.ext.koin.androidContext -import org.koin.androidx.workmanager.koin.workManagerFactory -import org.koin.plugin.module.dsl.startKoin -import org.meshtastic.app.di.AndroidKoinApp -import org.meshtastic.core.common.ContextServices -import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.service.worker.MeshLogCleanupWorker -import org.meshtastic.feature.widget.LocalStatsWidgetReceiver -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration - -/** - * The main application class for Meshtastic. - * - * This class initializes core application components using Koin for dependency injection. - */ -open class MeshUtilApplication : - Application(), - Configuration.Provider { - - private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - override fun onCreate() { - super.onCreate() - ContextServices.app = this - - startKoin { - androidContext(this@MeshUtilApplication) - workManagerFactory() - } - - // Schedule periodic MeshLog cleanup - scheduleMeshLogCleanup() - - // Generate and publish widget preview for Android 15+ widget picker - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - applicationScope.launch { - suspend fun pushPreview() { - try { - Logger.i { "Pushing generated widget preview..." } - val result = - GlanceAppWidgetManager(this@MeshUtilApplication) - .setWidgetPreviews( - LocalStatsWidgetReceiver::class, - intSetOf(AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN), - ) - Logger.i { "setWidgetPreviews result: $result" } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Failed to set widget preview" } - } - } - - pushPreview() - - val widgetStateProvider: org.meshtastic.feature.widget.LocalStatsWidgetStateProvider = get() - try { - // Wait for real data for up to 30 seconds before pushing an updated preview - withTimeout(30.seconds) { - widgetStateProvider.state.first { it.showContent && it.nodeShortName != null } - } - - Logger.i { "Real node data acquired. Pushing updated widget preview." } - pushPreview() - } catch (e: TimeoutCancellationException) { - Logger.i(e) { "Timed out waiting for real node data for widget preview." } - } - } - } - - // Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB - applicationScope.launch { - val dbManager: DatabaseManager = get() - val meshPrefs: MeshPrefs = get() - dbManager.init(meshPrefs.deviceAddress.value) - } - } - - override fun onTerminate() { - // Shutdown managers (useful for Robolectric tests) - get().close() - applicationScope.cancel() - super.onTerminate() - org.koin.core.context.stopKoin() - } - - private fun scheduleMeshLogCleanup() { - val cleanupRequest = - PeriodicWorkRequestBuilder(repeatInterval = 1.hours.toJavaDuration()).build() - - WorkManager.getInstance(this) - .enqueueUniquePeriodicWork( - MeshLogCleanupWorker.WORK_NAME, - ExistingPeriodicWorkPolicy.UPDATE, - cleanupRequest, - ) - } - - override val workManagerConfiguration: Configuration - get() = Configuration.Builder().setWorkerFactory(get()).build() -} - -fun logAssert(executeReliableWrite: Boolean) { - if (!executeReliableWrite) { - val ex = AssertionError("Assertion failed") - Logger.e(ex) { "logAssert" } - throw ex - } -} diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt deleted file mode 100644 index 04f0350c8..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import org.koin.core.annotation.KoinApplication - -/** - * Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when - * [org.koin.plugin.module.dsl.startKoin] is called with this type parameter. - */ -@KoinApplication(modules = [AppKoinModule::class]) -object AndroidKoinApp diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt deleted file mode 100644 index 09f38eaef..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import android.app.Application -import android.content.Context -import android.hardware.usb.UsbManager -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.work.WorkManager -import com.hoho.android.usbserial.driver.ProbeTable -import com.hoho.android.usbserial.driver.UsbSerialProber -import org.koin.core.annotation.Module -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.ble.di.CoreBleAndroidModule -import org.meshtastic.core.ble.di.CoreBleModule -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.common.di.CoreCommonModule -import org.meshtastic.core.data.di.CoreDataAndroidModule -import org.meshtastic.core.data.di.CoreDataModule -import org.meshtastic.core.database.di.CoreDatabaseAndroidModule -import org.meshtastic.core.database.di.CoreDatabaseModule -import org.meshtastic.core.datastore.di.CoreDatastoreAndroidModule -import org.meshtastic.core.datastore.di.CoreDatastoreModule -import org.meshtastic.core.network.di.CoreNetworkAndroidModule -import org.meshtastic.core.network.di.CoreNetworkModule -import org.meshtastic.core.network.repository.ProbeTableProvider -import org.meshtastic.core.prefs.di.CorePrefsAndroidModule -import org.meshtastic.core.prefs.di.CorePrefsModule -import org.meshtastic.core.service.di.CoreServiceAndroidModule -import org.meshtastic.core.service.di.CoreServiceModule -import org.meshtastic.core.takserver.di.CoreTakServerModule -import org.meshtastic.core.ui.di.CoreUiModule -import org.meshtastic.feature.connections.di.FeatureConnectionsModule -import org.meshtastic.feature.firmware.di.FeatureFirmwareModule -import org.meshtastic.feature.intro.di.FeatureIntroModule -import org.meshtastic.feature.map.di.FeatureMapModule -import org.meshtastic.feature.messaging.di.FeatureMessagingModule -import org.meshtastic.feature.node.di.FeatureNodeModule -import org.meshtastic.feature.settings.di.FeatureSettingsModule -import org.meshtastic.feature.widget.di.FeatureWidgetModule -import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule - -@Module( - includes = - [ - org.meshtastic.app.MainKoinModule::class, - org.meshtastic.core.di.di.CoreDiModule::class, - CoreCommonModule::class, - CoreBleModule::class, - CoreBleAndroidModule::class, - CoreDataModule::class, - CoreDataAndroidModule::class, - org.meshtastic.core.domain.di.CoreDomainModule::class, - CoreDatabaseModule::class, - CoreDatabaseAndroidModule::class, - org.meshtastic.core.repository.di.CoreRepositoryModule::class, - CoreDatastoreModule::class, - CoreDatastoreAndroidModule::class, - CorePrefsModule::class, - CorePrefsAndroidModule::class, - CoreServiceModule::class, - CoreServiceAndroidModule::class, - CoreNetworkModule::class, - CoreNetworkAndroidModule::class, - CoreTakServerModule::class, - CoreUiModule::class, - FeatureNodeModule::class, - FeatureMessagingModule::class, - FeatureConnectionsModule::class, - FeatureMapModule::class, - FeatureSettingsModule::class, - FeatureFirmwareModule::class, - FeatureIntroModule::class, - FeatureWidgetModule::class, - FeatureWifiProvisionModule::class, - NetworkModule::class, - FlavorModule::class, - ], -) -class AppKoinModule { - @Single - @Named("ProcessLifecycle") - fun provideProcessLifecycle(): Lifecycle = ProcessLifecycleOwner.get().lifecycle - - @Single - fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider { - override val isDebug: Boolean = org.meshtastic.app.BuildConfig.DEBUG - override val applicationId: String = org.meshtastic.app.BuildConfig.APPLICATION_ID - override val versionCode: Int = org.meshtastic.app.BuildConfig.VERSION_CODE - override val versionName: String = org.meshtastic.app.BuildConfig.VERSION_NAME - override val absoluteMinFwVersion: String = org.meshtastic.app.BuildConfig.ABS_MIN_FW_VERSION - override val minFwVersion: String = org.meshtastic.app.BuildConfig.MIN_FW_VERSION - } - - @Single fun provideWorkManager(context: Application): WorkManager = WorkManager.getInstance(context) - - @Single - fun provideUsbManager(application: Application): UsbManager? = - application.getSystemService(Context.USB_SERVICE) as UsbManager? - - @Single fun provideProbeTable(provider: ProbeTableProvider): ProbeTable = provider.get() - - @Single fun provideUsbSerialProber(probeTable: ProbeTable): UsbSerialProber = UsbSerialProber(probeTable) -} diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt deleted file mode 100644 index 91ab81ec0..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.di - -import android.app.Application -import android.content.Context -import android.net.ConnectivityManager -import android.net.nsd.NsdManager -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.disk.DiskCache -import coil3.memory.MemoryCache -import coil3.memoryCacheMaxSizePercentWhileInBackground -import coil3.network.DeDupeConcurrentRequestStrategy -import coil3.network.ktor3.KtorNetworkFetcherFactory -import coil3.request.crossfade -import coil3.svg.SvgDecoder -import coil3.util.DebugLogger -import coil3.util.Logger -import io.ktor.client.HttpClient -import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.request.url -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import okio.Path.Companion.toOkioPath -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.network.HttpClientDefaults -import org.meshtastic.core.network.KermitHttpLogger - -private const val DISK_CACHE_PERCENT = 0.02 -private const val MEMORY_CACHE_PERCENT = 0.25 -private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1 - -@Module -class NetworkModule { - - @Single - fun provideConnectivityManager(application: Application): ConnectivityManager = - application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - @Single - fun provideNsdManager(application: Application): NsdManager = - application.getSystemService(Context.NSD_SERVICE) as NsdManager - - @OptIn(ExperimentalCoilApi::class) - @Single - fun provideImageLoader( - httpClient: HttpClient, - application: Context, - buildConfigProvider: BuildConfigProvider, - ): ImageLoader = ImageLoader.Builder(context = application) - .components { - add( - KtorNetworkFetcherFactory( - httpClient = httpClient, - concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(), - ), - ) - add(SvgDecoder.Factory(scaleToDensity = true)) - } - .memoryCache { - MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build() - } - .diskCache { - DiskCache.Builder() - .directory(application.cacheDir.resolve("image_cache").toOkioPath()) - .maxSizePercent(percent = DISK_CACHE_PERCENT) - .build() - } - .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null) - .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT) - .crossfade(enable = true) - .build() - - @Single - fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient = - HttpClient(engineFactory = Android) { - install(plugin = ContentNegotiation) { json(json) } - install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) } - install(plugin = HttpTimeout) { - requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS - } - install(plugin = HttpRequestRetry) { - retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES) - exponentialDelay() - } - if (buildConfigProvider.isDebug) { - install(plugin = Logging) { - logger = KermitHttpLogger - level = LogLevel.BODY - } - } - } -} diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt deleted file mode 100644 index 1e5b68ab0..000000000 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("MatchingDeclarationName") - -package org.meshtastic.app.ui - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.recalculateWindowInsets -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import co.touchlab.kermit.Logger -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.BuildConfig -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.navigation.NodesRoute -import org.meshtastic.core.navigation.rememberMultiBackstack -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.app_too_old -import org.meshtastic.core.resources.must_update -import org.meshtastic.core.ui.component.MeshtasticAppShell -import org.meshtastic.core.ui.component.MeshtasticNavDisplay -import org.meshtastic.core.ui.component.MeshtasticNavigationSuite -import org.meshtastic.core.ui.viewmodel.UIViewModel -import org.meshtastic.feature.connections.navigation.connectionsGraph -import org.meshtastic.feature.firmware.navigation.firmwareGraph -import org.meshtastic.feature.map.navigation.mapGraph -import org.meshtastic.feature.messaging.navigation.contactsGraph -import org.meshtastic.feature.node.navigation.nodesGraph -import org.meshtastic.feature.settings.navigation.settingsGraph -import org.meshtastic.feature.settings.radio.channel.channelsGraph -import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph - -@Composable -fun MainScreen() { - val viewModel: UIViewModel = koinViewModel() - val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph) - val backStack = multiBackstack.activeBackStack - - AndroidAppVersionCheck(viewModel) - - MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) { - MeshtasticNavigationSuite( - multiBackstack = multiBackstack, - uiViewModel = viewModel, - modifier = Modifier.fillMaxSize(), - ) { - val provider = - entryProvider { - contactsGraph(backStack, viewModel.scrollToTopEventFlow) - nodesGraph( - backStack = backStack, - scrollToTopEvents = viewModel.scrollToTopEventFlow, - onHandleDeepLink = viewModel::handleDeepLink, - ) - mapGraph(backStack) - channelsGraph(backStack) - connectionsGraph(backStack) - settingsGraph(backStack) - firmwareGraph(backStack) - wifiProvisionGraph(backStack) - } - MeshtasticNavDisplay( - multiBackstack = multiBackstack, - entryProvider = provider, - modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(), - ) - } - } -} - -@Composable -@Suppress("LongMethod", "CyclomaticComplexMethod") -private fun AndroidAppVersionCheck(viewModel: UIViewModel) { - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() - - LaunchedEffect(connectionState, myNodeInfo) { - if (connectionState == ConnectionState.Connected) { - myNodeInfo?.let { info -> - val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not() - Logger.d { - "[FW_CHECK] App version check - minAppVersion: ${info.minAppVersion}, " + - "currentVersion: ${BuildConfig.VERSION_CODE}, isOld: $isOld" - } - - if (isOld) { - Logger.w { "[FW_CHECK] App too old - showing update prompt" } - viewModel.showAlert( - titleRes = Res.string.app_too_old, - messageRes = Res.string.must_update, - onConfirm = { viewModel.setDeviceAddress("n") }, - ) - } - } - } - } -} diff --git a/fastlane/metadata/android/contact-email.txt b/app/src/main/play/contact-email.txt similarity index 100% rename from fastlane/metadata/android/contact-email.txt rename to app/src/main/play/contact-email.txt diff --git a/fastlane/metadata/android/contact-website.txt b/app/src/main/play/contact-website.txt similarity index 100% rename from fastlane/metadata/android/contact-website.txt rename to app/src/main/play/contact-website.txt diff --git a/fastlane/metadata/android/default-language.txt b/app/src/main/play/default-language.txt similarity index 100% rename from fastlane/metadata/android/default-language.txt rename to app/src/main/play/default-language.txt diff --git a/app/src/main/play/listings/en-US/full-description.txt b/app/src/main/play/listings/en-US/full-description.txt new file mode 100644 index 000000000..32f45f058 --- /dev/null +++ b/app/src/main/play/listings/en-US/full-description.txt @@ -0,0 +1,3 @@ +This is a beta release of the meshtastic.org project. We'd love you to try it and tell us what you think. You'll need to buy an inexpensive ($30ish) radio from a variety of vendors to use this application, see our website for details. We don't make these devices. + +***Please*** if you encounter problems or have questions: post on our forum at meshtastic.discourse.group and we'll work together to fix them (we are volunteer hobbyists). We would really appreciate good Google reviews if you think this is a good project. diff --git a/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png b/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png new file mode 100644 index 000000000..044cee993 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/feature-graphic/1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/icon/1.png b/app/src/main/play/listings/en-US/graphics/icon/1.png new file mode 100644 index 000000000..c4069fac7 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/icon/1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png new file mode 100644 index 000000000..78a81dccc Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/1.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png new file mode 100644 index 000000000..a884644e0 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/2.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png new file mode 100644 index 000000000..c198e8b39 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/3.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png new file mode 100644 index 000000000..63d3f1e20 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/4.png differ diff --git a/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png b/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png new file mode 100644 index 000000000..1ed4e9df3 Binary files /dev/null and b/app/src/main/play/listings/en-US/graphics/phone-screenshots/5.png differ diff --git a/app/src/main/play/listings/en-US/short-description.txt b/app/src/main/play/listings/en-US/short-description.txt new file mode 100644 index 000000000..7d1be0bfd --- /dev/null +++ b/app/src/main/play/listings/en-US/short-description.txt @@ -0,0 +1 @@ +An inexpensive open-source GPS mesh radio for hiking, skiing, flying, marching. diff --git a/app/src/main/play/listings/en-US/title.txt b/app/src/main/play/listings/en-US/title.txt new file mode 100644 index 000000000..efef08b61 --- /dev/null +++ b/app/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Meshtastic diff --git a/app/src/main/play/listings/en-US/video-url.txt b/app/src/main/play/listings/en-US/video-url.txt new file mode 100644 index 000000000..39b75c452 --- /dev/null +++ b/app/src/main/play/listings/en-US/video-url.txt @@ -0,0 +1 @@ +https://www.youtube.com/watch?v=TY6m6fS8bxU diff --git a/app/src/main/play/release-notes/en-US/alpha.txt b/app/src/main/play/release-notes/en-US/alpha.txt new file mode 100644 index 000000000..e4da04080 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/alpha.txt @@ -0,0 +1 @@ +For more information visit meshtastic.org. This application is made by volunteers. We are friendly and actively respond to forum posts with any questions you have. Post at meshtastic.discourse.group and we'll help. diff --git a/app/src/main/play/release-notes/en-US/beta.txt b/app/src/main/play/release-notes/en-US/beta.txt new file mode 100644 index 000000000..e4da04080 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/beta.txt @@ -0,0 +1 @@ +For more information visit meshtastic.org. This application is made by volunteers. We are friendly and actively respond to forum posts with any questions you have. Post at meshtastic.discourse.group and we'll help. diff --git a/app/src/main/play/release-notes/en-US/internal.txt b/app/src/main/play/release-notes/en-US/internal.txt new file mode 100644 index 000000000..322aae877 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/internal.txt @@ -0,0 +1 @@ +An internal build diff --git a/app/src/main/play/release-notes/en-US/production.txt b/app/src/main/play/release-notes/en-US/production.txt new file mode 100644 index 000000000..e4da04080 --- /dev/null +++ b/app/src/main/play/release-notes/en-US/production.txt @@ -0,0 +1 @@ +For more information visit meshtastic.org. This application is made by volunteers. We are friendly and actively respond to forum posts with any questions you have. Post at meshtastic.discourse.group and we'll help. diff --git a/app/src/main/proto b/app/src/main/proto new file mode 160000 index 000000000..06cf134e2 --- /dev/null +++ b/app/src/main/proto @@ -0,0 +1 @@ +Subproject commit 06cf134e2b3d035c3ca6cbbb90b4c017d4715398 diff --git a/app/src/main/res/color/tab_color_selector.xml b/app/src/main/res/color/tab_color_selector.xml new file mode 100644 index 000000000..79262078c --- /dev/null +++ b/app/src/main/res/color/tab_color_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/app_icon.xml b/app/src/main/res/drawable-anydpi/app_icon.xml index 2c6079c25..3b5436367 100644 --- a/app/src/main/res/drawable-anydpi/app_icon.xml +++ b/app/src/main/res/drawable-anydpi/app_icon.xml @@ -1,21 +1,3 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_splash.xml b/app/src/main/res/drawable-anydpi/ic_splash.xml deleted file mode 100644 index 4357c9a48..000000000 --- a/app/src/main/res/drawable-anydpi/ic_splash.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-hdpi/app_icon.png b/app/src/main/res/drawable-hdpi/app_icon.png new file mode 100644 index 000000000..8f86071ee Binary files /dev/null and b/app/src/main/res/drawable-hdpi/app_icon.png differ diff --git a/app/src/main/res/drawable-hdpi/app_icon_novect.png b/app/src/main/res/drawable-hdpi/app_icon_novect.png new file mode 120000 index 000000000..ef5958e92 --- /dev/null +++ b/app/src/main/res/drawable-hdpi/app_icon_novect.png @@ -0,0 +1 @@ +app_icon.png \ No newline at end of file diff --git a/app/src/main/res/drawable-mdpi/app_icon.png b/app/src/main/res/drawable-mdpi/app_icon.png new file mode 100644 index 000000000..f432e8db3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/app_icon.png differ diff --git a/app/src/main/res/drawable-mdpi/app_icon_novect.png b/app/src/main/res/drawable-mdpi/app_icon_novect.png new file mode 120000 index 000000000..ef5958e92 --- /dev/null +++ b/app/src/main/res/drawable-mdpi/app_icon_novect.png @@ -0,0 +1 @@ +app_icon.png \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/channel_name_image.jpg b/app/src/main/res/drawable-nodpi/channel_name_image.jpg new file mode 100644 index 000000000..271bc5241 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/channel_name_image.jpg differ diff --git a/app/src/main/res/drawable-nodpi/icon_meanings.png b/app/src/main/res/drawable-nodpi/icon_meanings.png new file mode 100644 index 000000000..3635df2e8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/icon_meanings.png differ diff --git a/core/resources/src/commonMain/composeResources/drawable/img_qrcode.png b/app/src/main/res/drawable-nodpi/qrcode.png similarity index 100% rename from core/resources/src/commonMain/composeResources/drawable/img_qrcode.png rename to app/src/main/res/drawable-nodpi/qrcode.png diff --git a/app/src/main/res/drawable-xhdpi/app_icon.png b/app/src/main/res/drawable-xhdpi/app_icon.png new file mode 100644 index 000000000..f295a5a22 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/app_icon.png differ diff --git a/app/src/main/res/drawable-xhdpi/app_icon_novect.png b/app/src/main/res/drawable-xhdpi/app_icon_novect.png new file mode 120000 index 000000000..ef5958e92 --- /dev/null +++ b/app/src/main/res/drawable-xhdpi/app_icon_novect.png @@ -0,0 +1 @@ +app_icon.png \ No newline at end of file diff --git a/app/src/main/res/drawable-xxhdpi/app_icon.png b/app/src/main/res/drawable-xxhdpi/app_icon.png new file mode 100644 index 000000000..ee1231857 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/app_icon.png differ diff --git a/app/src/main/res/drawable-xxhdpi/app_icon_novect.png b/app/src/main/res/drawable-xxhdpi/app_icon_novect.png new file mode 120000 index 000000000..ef5958e92 --- /dev/null +++ b/app/src/main/res/drawable-xxhdpi/app_icon_novect.png @@ -0,0 +1 @@ +app_icon.png \ No newline at end of file diff --git a/app/src/main/res/drawable/cloud_download_outline_24.xml b/app/src/main/res/drawable/cloud_download_outline_24.xml new file mode 100644 index 000000000..4301600f9 --- /dev/null +++ b/app/src/main/res/drawable/cloud_download_outline_24.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cloud_off.xml b/app/src/main/res/drawable/cloud_off.xml new file mode 100644 index 000000000..92b0aa54f --- /dev/null +++ b/app/src/main/res/drawable/cloud_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/cloud_on.xml b/app/src/main/res/drawable/cloud_on.xml new file mode 100644 index 000000000..0c9bcd4d5 --- /dev/null +++ b/app/src/main/res/drawable/cloud_on.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml b/app/src/main/res/drawable/ic_antenna_24.xml similarity index 56% rename from core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml rename to app/src/main/res/drawable/ic_antenna_24.xml index bdbe21f5c..c806236a9 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml +++ b/app/src/main/res/drawable/ic_antenna_24.xml @@ -1,29 +1,12 @@ - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml new file mode 100644 index 000000000..e13f29fd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 000000000..2cda9c112 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml new file mode 100644 index 000000000..ac50e268d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml b/app/src/main/res/drawable/ic_baseline_location_on_24.xml similarity index 84% rename from core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml rename to app/src/main/res/drawable/ic_baseline_location_on_24.xml index 620fd9336..3bf5b7133 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml +++ b/app/src/main/res/drawable/ic_baseline_location_on_24.xml @@ -6,9 +6,9 @@ diff --git a/app/src/main/res/drawable/ic_battery_alert.xml b/app/src/main/res/drawable/ic_battery_alert.xml new file mode 100644 index 000000000..aab98bc9d --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_alert.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_high.xml b/app/src/main/res/drawable/ic_battery_high.xml new file mode 100644 index 000000000..032956f30 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_high.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery_low.xml b/app/src/main/res/drawable/ic_battery_low.xml new file mode 100644 index 000000000..2126c0bc3 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_low.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery_medium.xml b/app/src/main/res/drawable/ic_battery_medium.xml new file mode 100644 index 000000000..e60a81575 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_medium.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_outline.xml b/app/src/main/res/drawable/ic_battery_outline.xml new file mode 100644 index 000000000..ec515ed01 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_outline.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_unknown.xml b/app/src/main/res/drawable/ic_battery_unknown.xml new file mode 100644 index 000000000..6be9c7145 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_unknown.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher2_background.xml b/app/src/main/res/drawable/ic_launcher2_background.xml new file mode 100644 index 000000000..8bd903402 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher2_background.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher2_foreground.xml similarity index 100% rename from app/src/main/res/drawable-anydpi/ic_launcher_foreground.xml rename to app/src/main/res/drawable/ic_launcher2_foreground.xml diff --git a/app/src/main/res/drawable/ic_lock_open_right_24.xml b/app/src/main/res/drawable/ic_lock_open_right_24.xml new file mode 100644 index 000000000..243cd83d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_open_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml b/app/src/main/res/drawable/ic_map_location_dot_24.xml similarity index 86% rename from core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml rename to app/src/main/res/drawable/ic_map_location_dot_24.xml index fb313c150..2fb6587cc 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml +++ b/app/src/main/res/drawable/ic_map_location_dot_24.xml @@ -7,5 +7,5 @@ android:fillColor="#3388ff" android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" android:strokeWidth="2.0" - android:strokeColor="#ffffffff" /> + android:strokeColor="@android:color/white" /> diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml b/app/src/main/res/drawable/ic_map_navigation_24.xml similarity index 86% rename from core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml rename to app/src/main/res/drawable/ic_map_navigation_24.xml index 387e9db8b..6557bb984 100644 --- a/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml +++ b/app/src/main/res/drawable/ic_map_navigation_24.xml @@ -7,5 +7,5 @@ android:fillColor="#3388ff" android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z" android:strokeWidth="1.5" - android:strokeColor="#ffffffff" /> + android:strokeColor="@android:color/white" /> diff --git a/app/src/main/res/drawable/ic_outlined_dew_point_24.xml b/app/src/main/res/drawable/ic_outlined_dew_point_24.xml new file mode 100644 index 000000000..e19263e2e --- /dev/null +++ b/app/src/main/res/drawable/ic_outlined_dew_point_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_power_plug_24.xml b/app/src/main/res/drawable/ic_power_plug_24.xml new file mode 100644 index 000000000..09f2effb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_power_plug_24.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index 3f20873d9..000000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_satellite.xml b/app/src/main/res/drawable/ic_satellite.xml new file mode 100644 index 000000000..b9bbd50aa --- /dev/null +++ b/app/src/main/res/drawable/ic_satellite.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/ic_twotone_add_24.xml b/app/src/main/res/drawable/ic_twotone_add_24.xml new file mode 100644 index 000000000..eb232541d --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml b/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml new file mode 100644 index 000000000..8982a35a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_cloud_upload_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_contactless_24.xml b/app/src/main/res/drawable/ic_twotone_contactless_24.xml new file mode 100644 index 000000000..8a999651b --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_contactless_24.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml b/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml new file mode 100644 index 000000000..21fc0ae3a --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_content_paste_go_24.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_twotone_delete_24.xml b/app/src/main/res/drawable/ic_twotone_delete_24.xml new file mode 100644 index 000000000..b77afdc91 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_delete_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_lock_24.xml b/app/src/main/res/drawable/ic_twotone_lock_24.xml new file mode 100644 index 000000000..2c454424f --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_lock_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_map_24.xml b/app/src/main/res/drawable/ic_twotone_map_24.xml new file mode 100644 index 000000000..bd96aeb9f --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_map_24.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_message_24.xml b/app/src/main/res/drawable/ic_twotone_message_24.xml new file mode 100644 index 000000000..9ad22351e --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_message_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_people_24.xml b/app/src/main/res/drawable/ic_twotone_people_24.xml new file mode 100644 index 000000000..74a346d8f --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_people_24.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_twotone_select_all_24.xml b/app/src/main/res/drawable/ic_twotone_select_all_24.xml new file mode 100644 index 000000000..c997121c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_select_all_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_twotone_send_24.xml b/app/src/main/res/drawable/ic_twotone_send_24.xml new file mode 100644 index 000000000..ee3e89f79 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_send_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml b/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml new file mode 100644 index 000000000..e2368d7a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_settings_applications_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_sort_24.xml b/app/src/main/res/drawable/ic_twotone_sort_24.xml new file mode 100644 index 000000000..a58bd6881 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_sort_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_twotone_visibility_24.xml b/app/src/main/res/drawable/ic_twotone_visibility_24.xml new file mode 100644 index 000000000..507c5fdb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_visibility_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml b/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml new file mode 100644 index 000000000..a19e1d4b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_visibility_off_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_volume_off_24.xml b/app/src/main/res/drawable/ic_twotone_volume_off_24.xml new file mode 100644 index 000000000..a62bb87b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_volume_off_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_volume_up_24.xml b/app/src/main/res/drawable/ic_twotone_volume_up_24.xml new file mode 100644 index 000000000..94f1e4c67 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_volume_up_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..512220495 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_add_quick_chat.xml b/app/src/main/res/layout/dialog_add_quick_chat.xml new file mode 100644 index 000000000..da559bc80 --- /dev/null +++ b/app/src/main/res/layout/dialog_add_quick_chat.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_debug.xml b/app/src/main/res/layout/fragment_debug.xml new file mode 100644 index 000000000..ea3aeba0a --- /dev/null +++ b/app/src/main/res/layout/fragment_debug.xml @@ -0,0 +1,58 @@ + + + + + +