diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a62ac0ba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.md] +max_line_length = 70 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..77d90325 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @awatchar + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2fa018ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug report +description: Report a reproducible problem in the Android app or configuration portal. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thank you for testing Minimum. Do not include server passwords, access tokens, device bearer tokens, APRS passcodes, certificate fingerprints, exact private coordinates, or unredacted configuration files. + - type: dropdown + id: component + attributes: + label: Component + options: + - Android radio application + - Configuration portal + - Managed configuration delivery + - Provisioning script + - APRS tracking + - Build or release + - Documentation + - Other + validations: + required: true + - type: input + id: version + attributes: + label: Version or commit + description: Find the Android version in App info, or provide the tested Git commit. + placeholder: e.g. 5a28ff5-debug + validations: + required: true + - type: dropdown + id: model + attributes: + label: Device model + options: + - T56 + - T99 + - Generic Android + - Web browser only + - Other + validations: + required: true + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the observed result and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: | + 1. Start with ... + 2. Press ... + 3. Observe ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Test conditions + description: Include screen state, network type, connected server/channel (sanitized), and whether the app was foreground, background, or starting after boot. + - type: textarea + id: evidence + attributes: + label: Sanitized evidence + description: Attach screenshots, video, timestamps, or a minimal redacted log. Do not upload secrets or a complete bugreport. + - type: checkboxes + id: safety + attributes: + label: Safety checks + options: + - label: I stopped transmitting before collecting or uploading diagnostics. + required: true + - label: I checked the report and attachments for credentials, tokens, exact private coordinates, and other private device data. + required: true + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..6037a76b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/awatchar/minimum/security/advisories/new + about: Report credentials, authentication bypasses, or other vulnerabilities privately. + diff --git a/.github/ISSUE_TEMPLATE/device_acceptance.yml b/.github/ISSUE_TEMPLATE/device_acceptance.yml new file mode 100644 index 00000000..5866ac65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device_acceptance.yml @@ -0,0 +1,77 @@ +name: Device acceptance result +description: Record a structured T56, T99, or generic Android field-test result. +title: "[Device test]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Use this form for real-device failures or acceptance evidence. Never publish credentials, bearer tokens, APRS passcodes, certificate fingerprints, exact private coordinates, or unsanitized ADB output. A passing result may have its `bug` label removed during triage. + - type: dropdown + id: model + attributes: + label: Model + options: [T56, T99, Generic Android] + validations: + required: true + - type: input + id: build + attributes: + label: APK version or commit + placeholder: e.g. 5a28ff5-debug + validations: + required: true + - type: dropdown + id: scenario + attributes: + label: Scenario + options: + - Boot to ready channel + - Screen-off PTT + - Foreground PTT + - Network loss and reconnect + - Server or channel switch + - Multiple simultaneous speakers + - Device ID display + - Managed config refresh or rollback + - Location or APRS tracking + - Provisioning + - Other + validations: + required: true + - type: dropdown + id: result + attributes: + label: Result + options: [PASS, FAIL, PARTIAL, BLOCKED] + validations: + required: true + - type: textarea + id: preconditions + attributes: + label: Preconditions + description: State screen/app/network conditions and any relevant setup without secrets. + validations: + required: true + - type: textarea + id: procedure + attributes: + label: Procedure and observations + description: Include exact operator actions, timing, visible status, TX/RX behavior, and recovery. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Sanitized evidence + description: Attach timestamps, screenshots, video, or only the smallest redacted log needed to support the result. + - type: checkboxes + id: safeguards + attributes: + label: Safeguards + options: + - label: Transmissions were supervised and stopped before failure recovery or diagnostic collection. + required: true + - label: Evidence was reviewed and contains no credentials, tokens, exact private coordinates, or other private device data. + required: true + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..26c0097c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature request +description: Propose a focused improvement to Minimum. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: dropdown + id: component + attributes: + label: Component + options: + - Android radio application + - Configuration portal + - Provisioning + - APRS tracking + - Operations or release + - Documentation + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Describe the operator or administrator problem, not only the proposed UI. + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Explain the observable behavior and how it could be accepted as complete. + validations: + required: true + - type: textarea + id: constraints + attributes: + label: Constraints and safety considerations + description: Note affected models, networks, permissions, compatibility, privacy, or TX safety concerns. + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e4d9dd4a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +## Outcome + +Describe the user-visible behavior and why this change is needed. Link the issue with `Closes #...` when applicable. + +## Scope + +- Components changed: +- Explicitly out of scope: +- Configuration schema or migration impact: + +## Verification + +- [ ] Relevant automated tests pass. +- [ ] `:app:testFossDebugUnitTest :app:assembleFossDebug` passes for Android changes. +- [ ] Web tests, TypeScript and production build pass for portal changes. +- [ ] Real-device acceptance evidence is linked when hardware behavior changed. +- [ ] Documentation and hand-off status are updated. + +List exact commands and manual scenarios with their results: + +## Safety and privacy + +- [ ] No server password, access token, bearer token, APRS passcode, signing material, exact private coordinates, or unsanitized device data is committed or attached. +- [ ] PTT changes preserve release-on-key-up, network loss, disconnect, service destruction and watchdog behavior. +- [ ] Reconnect/config changes preserve throttling, last-known-good fallback and selected-channel restoration. +- [ ] Public location behavior and operator consent were reviewed when tracking changed. + +## Release impact + +- [ ] No release note required. +- [ ] Release note or known limitation is included below. + +Release note / known limitation: diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..adf4c40f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,11 @@ +# Security policy + +Report authentication bypasses, credential exposure, unsafe transmit behavior and other security +issues privately with [GitHub Security Advisories](https://github.com/awatchar/minimum/security/advisories/new). +Do not open a public Issue containing server passwords, access tokens, device bearer tokens, APRS +passcodes, signing keys, certificate fingerprints, precise private coordinates or complete device +logs. + +Include the affected commit/version, device model, observed impact and the smallest sanitized +reproduction details. Do not perform unsupervised radio transmission, destructive provisioning or +location publication to collect evidence. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d14f8378 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: Continuous integration + +on: + pull_request: + push: + branches: [main, agent/minimum-foundation] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + name: Android tests, debug APK and release lint + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout with Humla + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + - name: Install pinned NDK + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;25.1.8937393" + - name: Build patched Spongy Castle jars + working-directory: libraries/humla/libs/humla-spongycastle + run: | + chmod +x ../../gradlew + ../../gradlew jar --no-daemon + - name: Test and assemble FOSS debug and unsigned release + run: | + chmod +x gradlew + ./gradlew :app:testFossDebugUnitTest :app:assembleFossDebug :app:assembleFossRelease --no-daemon --stacktrace + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: minimum-foss-debug-${{ github.sha }} + path: app/build/outputs/apk/foss/debug/mumla-foss-debug.apk + if-no-files-found: error + retention-days: 14 + + web: + name: Portal tests and production build + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: web + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.16.0 + run_install: false + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Test + run: pnpm test + - name: Type-check + run: pnpm exec tsc --noEmit + - name: Build + run: pnpm build diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 00000000..7d7c7dff --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,37 @@ +name: Deploy public recovery configuration + +on: + push: + branches: [main] + paths: + - "backend/**" + - ".github/workflows/deploy-pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload public recovery configuration + uses: actions/upload-pages-artifact@v3 + with: + path: backend + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml new file mode 100644 index 00000000..bf0f29df --- /dev/null +++ b/.github/workflows/release-apk.yml @@ -0,0 +1,140 @@ +name: Release signed APK + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.1) + required: true + type: string + prerelease: + description: Mark the GitHub release as a prerelease + required: true + default: true + type: boolean + expected_version_code: + description: Android versionCode reviewed for this release + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + release: + name: Verify, sign and publish FOSS APK + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release + steps: + - name: Validate tag format + env: + RELEASE_TAG: ${{ inputs.tag }} + EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "Tag must begin with a numeric x.y.z version so Android versionName is deterministic." >&2 + exit 1 + fi + if [[ ! "$EXPECTED_VERSION_CODE" =~ ^[1-9][0-9]*$ ]]; then + echo "expected_version_code must be a positive integer." >&2 + exit 1 + fi + - name: Checkout release tag with Humla + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ inputs.tag }} + submodules: recursive + fetch-depth: 0 + - name: Verify required signing secrets + env: + MINIMUM_RELEASE_KEYSTORE_BASE64: ${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }} + MINIMUM_RELEASE_STORE_PASSWORD: ${{ secrets.MINIMUM_RELEASE_STORE_PASSWORD }} + MINIMUM_RELEASE_KEY_ALIAS: ${{ secrets.MINIMUM_RELEASE_KEY_ALIAS }} + MINIMUM_RELEASE_KEY_PASSWORD: ${{ secrets.MINIMUM_RELEASE_KEY_PASSWORD }} + MINIMUM_RELEASE_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }} + run: | + for value in \ + MINIMUM_RELEASE_KEYSTORE_BASE64 \ + MINIMUM_RELEASE_STORE_PASSWORD \ + MINIMUM_RELEASE_KEY_ALIAS \ + MINIMUM_RELEASE_KEY_PASSWORD \ + MINIMUM_RELEASE_APPLICATION_ID; do + if [[ -z "${!value}" ]]; then + echo "Required release secret $value is not configured." >&2 + exit 1 + fi + done + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: gradle + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + - name: Install pinned NDK + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;25.1.8937393" + - name: Materialize signing configuration + env: + KEYSTORE_BASE64: ${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.MINIMUM_RELEASE_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.MINIMUM_RELEASE_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.MINIMUM_RELEASE_KEY_PASSWORD }} + run: | + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/minimum-release.jks" + cat > app/signing.gradle <<'GRADLE' + android { + signingConfigs { + release { + storeFile file(System.getenv('MINIMUM_KEYSTORE_PATH')) + storePassword System.getenv('MINIMUM_STORE_PASSWORD') + keyAlias System.getenv('MINIMUM_KEY_ALIAS') + keyPassword System.getenv('MINIMUM_KEY_PASSWORD') + } + } + } + GRADLE + echo "MINIMUM_KEYSTORE_PATH=$RUNNER_TEMP/minimum-release.jks" >> "$GITHUB_ENV" + echo "MINIMUM_STORE_PASSWORD=$STORE_PASSWORD" >> "$GITHUB_ENV" + echo "MINIMUM_KEY_ALIAS=$KEY_ALIAS" >> "$GITHUB_ENV" + echo "MINIMUM_KEY_PASSWORD=$KEY_PASSWORD" >> "$GITHUB_ENV" + - name: Build patched Spongy Castle jars + working-directory: libraries/humla/libs/humla-spongycastle + run: | + chmod +x ../../gradlew + ../../gradlew jar --no-daemon + - name: Test and assemble signed FOSS release + run: | + chmod +x gradlew + ./gradlew :app:testFossDebugUnitTest :app:assembleFossRelease --no-daemon --stacktrace + - name: Verify APK signature and identity + env: + RELEASE_TAG: ${{ inputs.tag }} + EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }} + EXPECTED_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }} + run: | + APK=app/build/outputs/apk/foss/release/mumla-foss-release.apk + "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK" + "$ANDROID_HOME/build-tools/36.0.0/aapt" dump badging "$APK" | tee "$RUNNER_TEMP/apk-badging.txt" + grep -F "package: name='$EXPECTED_APPLICATION_ID'" "$RUNNER_TEMP/apk-badging.txt" + grep -F "versionCode='$EXPECTED_VERSION_CODE'" "$RUNNER_TEMP/apk-badging.txt" + grep -F "versionName='$RELEASE_TAG'" "$RUNNER_TEMP/apk-badging.txt" + cp "$APK" "minimum-${RELEASE_TAG}-foss.apk" + sha256sum "minimum-${RELEASE_TAG}-foss.apk" > "minimum-${RELEASE_TAG}-foss.apk.sha256" + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + PRERELEASE: ${{ inputs.prerelease }} + run: | + prerelease_flag=() + if [[ "$PRERELEASE" == "true" ]]; then prerelease_flag+=(--prerelease); fi + gh release create "$RELEASE_TAG" \ + "minimum-${RELEASE_TAG}-foss.apk" \ + "minimum-${RELEASE_TAG}-foss.apk.sha256" \ + --verify-tag --generate-notes --title "Minimum $RELEASE_TAG" "${prerelease_flag[@]}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..eb28dacf --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Gradle / Android Studio +build/ +.idea/ +.gradle/ +*.iml +signing.gradle +scripts/.secrets/ + +# Eclipse +bin/ +gen/ +.settings/ +.classpath +.project + +# Android +local.properties + +# Java +*.class + +# Minimum configuration portal +web/.next/ +web/node_modules/ +web/tsconfig.tsbuildinfo +web-dev*.log diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..cdf37d15 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,45 @@ +image: docker.io/quite/android-sdk-ndk:10 + +variables: + # get those pesky submodules + GIT_SUBMODULE_STRATEGY: recursive + +before_script: + - export GRADLE_USER_HOME=$(pwd)/.gradle + - chmod +x ./gradlew + +cache: + key: ${CI_PROJECT_ID} + paths: + - .gradle/ + +stages: + - build + +# lintDebug: +# stage: build +# script: +# - ./gradlew -Pci --console=plain -PbuildDir=lint :app:lintDebug + +assembleDebug: + stage: build + script: + # ANDROID_SDK_ROOT is already set in the android-sdk-ndk container image. + - pushd libraries/humla/libs/humla-spongycastle + - ../../gradlew jar + - popd + - ./gradlew assembleDebug + artifacts: + paths: + - app/build/outputs/apk/ + expire_in: 3 months + +# debugTests: +# stage: test +# script: +# - ./gradlew -Pci --console=plain :app:testDebugUnitTest + +# coverageTests: +# stage: test +# script: +# - ./gradlew -Pci --console=plain jacocoTestReport coveralls diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..786ac289 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "libraries/humla"] + path = libraries/humla + url = https://github.com/awatchar/minimum.git + branch = humla-minimum diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..78285cdd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Minimum multi-agent workflow + +These instructions apply to the entire repository. Use `docs/PROJECT_STATUS.md` as the project +status source of truth and `docs/CODEX_WORKFLOW.md` for the longer delegation contract. + +## Roles + +- Sol is the lead agent. Sol owns ambiguous requirements, architecture, prioritization, security + decisions, integration, final review and communication with the user. +- `luna_worker` handles clear, bounded, repeatable or high-volume subtasks whose results can be + independently checked. +- Sol remains accountable for every delegated result. A Luna result is not complete until Sol has + inspected its diff/evidence and rerun proportionate verification from the main working tree. + +## Required bounded contract + +Before delegating to Luna, Sol must state all of the following: + +1. Exact objective and expected behavior or artifact. +2. Exact files/components Luna may read or change. +3. Files, behaviors and decisions Luna must not touch. +4. Exact verification commands or inspections that must pass. +5. Required return format: files changed, behavior delivered, verification results, blockers and + residual risks. + +If any of these are materially ambiguous, Sol must resolve the ambiguity first. Luna must not widen +scope or make undelegated product/architecture decisions. + +## Delegation policy + +- Prefer Luna for mechanical implementation, focused tests, repetitive migrations, narrow scans, + log analysis and documentation updates with objective acceptance checks. +- Keep cross-cutting design, security/trust policy, hardware claims, destructive operations, + conflict resolution and final acceptance with Sol. +- Parallelize independent read-heavy work when useful. Avoid overlapping write scopes; assign one + owner per file/component or sequence the changes. +- Use the smallest useful reasoning effort. The personal `luna_worker` default is the user's + requested `max`; an explicit low/medium override is appropriate for simple work when the current + Codex surface supports it. +- Do not delegate merely to create activity. Small tasks with no useful separation may stay with + Sol. + +## Agent configuration safety + +The personal custom agent is `%USERPROFILE%\.codex\agents\luna-worker.toml` and uses +`name = "luna_worker"`, `model = "gpt-5.6-luna"` and `model_reasoning_effort = "max"`. + +Before changing that file, Sol must preserve existing settings, verify compatibility with the +installed/current Codex schema, show the proposed diff, apply only the reviewed change, parse the +TOML and smoke-test `luna_worker`. Never rewrite agent configuration silently. + +## Project safeguards + +- Preserve unrelated user changes and do not stage `DEV_ENVIRONMENT_REQUIREMENTS.md` unless the + user explicitly requests it. +- Never commit Mumble access tokens, private certificate fingerprints, credentials or unsanitized + device data. +- Do not claim T99/T56 screen-off hardware PTT support without a real device trace. +- Keep the existing Mumla/Humla protocol and service-owned PTT safety path intact unless Sol has + explicitly approved a bounded change. +- After completing the user's requested batch, Sol must re-check the Technical Brief gap analysis, + select the highest-value unblocked bounded slice, and continue it when safe and within the user's + existing authority. Hardware actions, transmissions and destructive tests retain their normal + evidence and authorization boundaries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9096f2a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing to Minimum + +Minimum is a managed-radio Android application with hardware-specific PTT, remote configuration and +public APRS behavior. Start with [the project status](docs/PROJECT_STATUS.md), then follow the +[GitHub issue, merge and APK release workflow](docs/GITHUB_RELEASE_WORKFLOW.md). + +Use the GitHub Issue forms for bugs, device acceptance results and feature requests. Do not publish +server passwords, channel access tokens, device bearer tokens, APRS passcodes, signing material, +certificate fingerprints, exact private coordinates or unsanitized device logs. Report security +issues privately through GitHub Security Advisories. + +Keep each change focused and link its Issue. Hardware, PTT, boot, reconnect, provisioning and APRS +claims require real-device evidence. Pull requests must preserve TX release safety, reconnect +throttling, Last Known Good configuration fallback and Last Selected Channel restoration. + +Before opening a pull request, run the commands in +[the development runbook](docs/DEVELOPMENT_RUNBOOK.md) and update the relevant status, decision, +device-profile or acceptance document. Never stage `DEV_ENVIRONMENT_REQUIREMENTS.md`. diff --git a/FUNDING.yml b/FUNDING.yml new file mode 120000 index 00000000..c12063e9 --- /dev/null +++ b/FUNDING.yml @@ -0,0 +1 @@ +.github/FUNDING.yml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 16564233..04570652 100644 --- a/README.md +++ b/README.md @@ -1 +1,153 @@ -# minimum \ No newline at end of file + +# Minimum — Public PoC Radio Client + +This repository is the Minimum managed-radio PoC built from Mumla/Humla for the T99 and T56. +The project keeps the Mumble protocol, TLS, Opus, audio and foreground-service core while adding a +hardware-first Android radio shell, device provisioning, managed Schema-3 configuration and +T56-only APRS tracking. The private administrator portal is deployed at +`https://minimum.vra.or.th/`; the checked-in GitHub Pages data remains a public recovery/reference +artifact rather than the managed device refresh path. + +Project status and T99 hardware notes: + +- [Canonical project status](docs/PROJECT_STATUS.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Development runbook](docs/DEVELOPMENT_RUNBOOK.md) +- [Configuration contract](docs/CONFIG_BACKEND.md) +- [Acceptance test matrix](docs/TEST_MATRIX.md) +- [Engineering decisions](docs/DECISIONS.md) +- [Work log](docs/WORK_LOG.md) +- [Implementation status](docs/IMPLEMENTATION_STATUS.md) +- [GitHub issue, merge and release workflow](docs/GITHUB_RELEASE_WORKFLOW.md) +- [Contributing](CONTRIBUTING.md) +- [Security policy](.github/SECURITY.md) +- [APRS tracking contract](docs/APRS_TRACKING.md) +- [T99 device profile](docs/T99_DEVICE_PROFILE.md) +- [T56 device profile](docs/T56_DEVICE_PROFILE.md) +- [Static configuration backend](backend/README.md) + +# Software maintenance situation + +Current maintainer [Daniel Lublin](https://lublin.se) +([@quite@mstdn.social](https://mstdn.social/@quite) on fedi) has very +little time to do voluntary work on Mumla. My focus is strictly on +maintaining stability and security. This includes migrations to newer +Android SDKs, as they become requirements by Google/Alphabet for even +getting updates published on Google Play. There are also other +maintenance and administrative work, which barely gets done in a +timely manner. + +At some point I expect Mumla to disappear from Google Play, because +there will be some requirement that I did not have time to fulfill. +Eventually it will also rot and no longer work well in general on +newer releases of Android. + +Mumla needs a new maintainer that can allocate time to take on, to +begin with, all these tasks. To maintain stability and security. And +then hopefully also work with the community on for example protocol +parity with desktop Mumble, support for various hardware accessories, +general usability, and new features. + +Until there is a new maintainer with time on their hands you cannot +expect new features, or even the continued existance of a usable +Mumble app for Android. + +# Mumla + +Mumla is a fork and continuation of +[Plumble](https://github.com/acomminos/Plumble), a robust GPLv3 Mumble +client for Android originally written by Andrew Comminos. It uses the +the [Humla](https://gitlab.com/quite/humla) protocol implementation +(forked from Comminos's +[Jumble](https://github.com/acomminos/Jumble)). + +Mumla should run on Android 5.0 (Lollipop, "L", SDK/API 21) and later. + +Mumla is available [on +F-Droid](https://f-droid.org/packages/se.lublin.mumla/). + +There is a small [landing page](https://mumla-app.gitlab.io/). + +## FAQs -- Frequently Asked Questions + +### Action that my user has permission for does not show up in overflow menu + +Question: The Mumble server I use has an ACL that should give my user +(or a group it's in) permission to carry out a specific action (like +"Move"). Why doesn't Mumla show this action in the overflow menu +(three dots) for a channel or user? + +Answer: Try to disconnect and then reconnect to the server. The +decision to show a menu item depending on whether the user has the +required permission is done upon connecting, when the UI is set up. It +is *not* updated on the fly if permissions change while connected. + +## Translations + +If you want to help out translating Mumla, the project is [on +Weblate](https://hosted.weblate.org/engage/mumla/) -- thanks for +gratis hosting of our libre project! + +## Repository submodules + +Note that this Mumla git repository has submodule(s). You either need +to clone it using `git clone --recursive`, or you need to get the +submodule(s) in place after cloning: + + git submodule update --init --recursive + +## Building on GNU/Linux + +Building is verified to work using JDK 21. So you typically want to +set and export the JAVA_HOME environment variable like `export +JAVA_HOME=/usr/lib/jvm/java-21-openjdk`. + +The Android SDK need to be specified as usual, for example by setting +`ANDROID_SDK_ROOT`, or writing it to local.properties as `echo +>local.properties sdk.dir=/home/user/Android/Sdk` + +TODO: humla-spongycastle should be built as a sub-project of Humla's +Gradle, but currently isn't. + + git submodule update --init --recursive + + pushd libraries/humla/libs/humla-spongycastle + ../../gradlew jar + popd + + ./gradlew assembleDebug + +If you get an error running out of Java heap space, try raising the +-Xmx in `./gradle.properties`. + +### Notes on NDK + +The NDK is the toolchain used for building the native code (C/C++) of +Humla. We specify the version needed using `ndkVersion` in +`libraries/humla/build.gradle`. + +We currently use Android Gradle Plugin (AGP) version 8.x, which should +come bundled with NDK 25.1.8937393 that we currently use. It is +typically installed in a directory in `~/Android/Sdk/ndk/`. Using +newer NDK might give build errors. See also: +https://developer.android.com/studio/projects/install-ndk + +If Android Studio does not automatically install the mentioned version +of the NDK in the mentioned directory, then you may be able to get it +installed by using the SDK Manager: + +- Click SDK Tools tab. +- Check "Show Package Details" +- In the list view, expand "NDK (Side by side)" +- Check 25.1.8937393 +- Click OK + +### Container image + +I'm also maintaining a container image that is used to build Mumla in +[.gitlab-ci.yml](.gitlab-ci.yml). Source at +https://gitlab.com/quite/android-sdk-ndk + +## License + +Mumla's [LICENSE](LICENSE) is GNU GPL v3. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..19783605 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 . + */ + +buildscript { + repositories { + mavenCentral() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + } +} +apply plugin: 'com.android.application' + +repositories { + mavenCentral() + google() +} + +def signingFile = file 'signing.gradle' +if (signingFile.exists()) apply from: signingFile +def signingBetaFile = file 'signing-beta.gradle' +if (signingBetaFile.exists()) apply from: signingBetaFile + +allprojects { + tasks.withType(JavaCompile) { + // TODO include deprecations at some point, but currently they are *many* + options.compilerArgs << "-Xlint:all" << "-Xlint:-deprecation" << "-Xlint:-dep-ann" + } +} + +android { + namespace = 'se.lublin.mumla' + compileSdk = 36 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + + base { + archivesName = "mumla" + } + + // gradle8: Need to enable this when defaultConfig below contains some custom BuildConfig + buildFeatures { + buildConfig = true + } + + def gitDescribe = providers.exec { + // Need --always because there are no tags in gitlab ci (right?) + commandLine "git", "describe", "--tags", "--match", "[0-9]*.[0-9]*.[0-9]*", "--always" + }.standardOutput.asText.get().trim() + + defaultConfig { + minSdkVersion 21 + targetSdk 36 + + applicationId "se.lublin.mumla" + // Remember: app_news_items_vX_Y_Z in src/main/res/values/strings.xml + // and NEWS_ITEMS in src/main/res/java/se/lublin/mumla/app/DialogUtils.java + // code:XYYZZbb (bb for build) + versionCode 3070300 + versionName gitDescribe + + buildConfigField "long", "TIMESTAMP", System.currentTimeMillis() + "L" + buildConfigField "String", "VERSIONTAG", "\"" + gitDescribe.split("-")[0] + "\"" + + testInstrumentationRunner "android.test.InstrumentationTestRunner" + } + + flavorDimensions "release" + productFlavors { + goog { + dimension "release" + applicationId "se.lublin.mumla" + } + foss { + dimension "release" + applicationId "se.lublin.mumla" + } + donation { + dimension "release" + applicationId "se.lublin.mumla.donation" + } + beta { + dimension "release" + applicationId "se.lublin.mumla.beta" + } + } + + buildTypes { + // signingConfig beta will override + release { + if (android.hasProperty("signingConfigs")) { + if (signingConfigs.hasProperty("release")) { + signingConfig = signingConfigs.release + } + if (signingConfigs.hasProperty("beta")) { + signingConfig = signingConfigs.beta + } + } + } + debug { + versionNameSuffix "-debug" + if (android.hasProperty("signingConfigs")) { + if (signingConfigs.hasProperty("beta")) { + signingConfig = signingConfigs.beta + } + } + } + } + lint { + abortOnError = true + // InvalidPackage is spongycastle-related + disable 'InvalidPackage', 'MissingTranslation' + explainIssues = true + ignoreWarnings = false + quiet = false + } + // betas may be released every minute + // TODO? dynamic stuff, have to rebuild a lot + applicationVariants.all { variant -> + if (variant.flavorName == "beta") { + variant.outputs.each { output -> + output.versionCodeOverride = System.currentTimeSeconds() / 60 + } + } + } + +} + +dependencies { + implementation project(":libraries:humla") + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.documentfile:documentfile:1.1.0' + implementation 'androidx.fragment:fragment:1.8.9' + implementation 'androidx.recyclerview:recyclerview:1.4.0' + implementation 'androidx.exifinterface:exifinterface:1.4.2' + implementation 'com.google.android.material:material:1.13.0' + implementation 'androidx.preference:preference:1.2.1' + implementation 'org.jsoup:jsoup:1.13.1' + implementation 'info.guardianproject.netcipher:netcipher:2.1.0' + + googImplementation 'com.android.billingclient:billing:7.1.1' + testImplementation 'junit:junit:4.13.2' + // Android's local unit-test jar only contains stubbed org.json methods. + testImplementation 'org.json:json:20251224' +} diff --git a/app/src/beta/AndroidManifest.xml b/app/src/beta/AndroidManifest.xml new file mode 100644 index 00000000..56bb2144 --- /dev/null +++ b/app/src/beta/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/beta/ic_launcher-playstore.png b/app/src/beta/ic_launcher-playstore.png new file mode 100644 index 00000000..175259bb Binary files /dev/null and b/app/src/beta/ic_launcher-playstore.png differ diff --git a/app/src/beta/java/se/lublin/mumla/app/StartupAction.java b/app/src/beta/java/se/lublin/mumla/app/StartupAction.java new file mode 100644 index 00000000..2a0424a6 --- /dev/null +++ b/app/src/beta/java/se/lublin/mumla/app/StartupAction.java @@ -0,0 +1,14 @@ +package se.lublin.mumla.app; + +import static se.lublin.mumla.app.DialogUtils.maybeShowNewsDialog; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +public class StartupAction implements IStartupAction { + @Override + public void execute(@NonNull Activity activity) { + maybeShowNewsDialog(activity); + } +} diff --git a/app/src/beta/res/drawable/ic_launcher_foreground.xml b/app/src/beta/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..7d35989d --- /dev/null +++ b/app/src/beta/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/app/src/beta/res/drawable/ic_mumla.xml b/app/src/beta/res/drawable/ic_mumla.xml new file mode 100644 index 00000000..3f4a15ba --- /dev/null +++ b/app/src/beta/res/drawable/ic_mumla.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..ef49c991 --- /dev/null +++ b/app/src/beta/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/beta/res/mipmap-hdpi/ic_launcher.png b/app/src/beta/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..dfc7ff70 Binary files /dev/null and b/app/src/beta/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-mdpi/ic_launcher.png b/app/src/beta/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..fee366f6 Binary files /dev/null and b/app/src/beta/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..859c26e4 Binary files /dev/null and b/app/src/beta/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..c86e0d37 Binary files /dev/null and b/app/src/beta/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..17fdfb18 Binary files /dev/null and b/app/src/beta/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/beta/res/values/ic_launcher_background.xml b/app/src/beta/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..907871cb --- /dev/null +++ b/app/src/beta/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #09194B + \ No newline at end of file diff --git a/app/src/beta/res/values/strings_notranslate.xml b/app/src/beta/res/values/strings_notranslate.xml new file mode 100644 index 00000000..9d6fae1f --- /dev/null +++ b/app/src/beta/res/values/strings_notranslate.xml @@ -0,0 +1,4 @@ + + + Mumla Beta + diff --git a/app/src/beta/res/xml/searchable.xml b/app/src/beta/res/xml/searchable.xml new file mode 100644 index 00000000..b0d931d6 --- /dev/null +++ b/app/src/beta/res/xml/searchable.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/donation/AndroidManifest.xml b/app/src/donation/AndroidManifest.xml new file mode 100644 index 00000000..4e829bb3 --- /dev/null +++ b/app/src/donation/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/donation/java/se/lublin/mumla/app/StartupAction.java b/app/src/donation/java/se/lublin/mumla/app/StartupAction.java new file mode 100644 index 00000000..2a0424a6 --- /dev/null +++ b/app/src/donation/java/se/lublin/mumla/app/StartupAction.java @@ -0,0 +1,14 @@ +package se.lublin.mumla.app; + +import static se.lublin.mumla.app.DialogUtils.maybeShowNewsDialog; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +public class StartupAction implements IStartupAction { + @Override + public void execute(@NonNull Activity activity) { + maybeShowNewsDialog(activity); + } +} diff --git a/app/src/donation/res/values/strings_notranslate.xml b/app/src/donation/res/values/strings_notranslate.xml new file mode 100644 index 00000000..23569340 --- /dev/null +++ b/app/src/donation/res/values/strings_notranslate.xml @@ -0,0 +1,4 @@ + + + Mumla* + diff --git a/app/src/donation/res/xml/searchable.xml b/app/src/donation/res/xml/searchable.xml new file mode 100644 index 00000000..5e500628 --- /dev/null +++ b/app/src/donation/res/xml/searchable.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/foss/AndroidManifest.xml b/app/src/foss/AndroidManifest.xml new file mode 100644 index 00000000..e559dbc4 --- /dev/null +++ b/app/src/foss/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/foss/java/se/lublin/mumla/app/StartupAction.java b/app/src/foss/java/se/lublin/mumla/app/StartupAction.java new file mode 100644 index 00000000..2a0424a6 --- /dev/null +++ b/app/src/foss/java/se/lublin/mumla/app/StartupAction.java @@ -0,0 +1,14 @@ +package se.lublin.mumla.app; + +import static se.lublin.mumla.app.DialogUtils.maybeShowNewsDialog; + +import android.app.Activity; + +import androidx.annotation.NonNull; + +public class StartupAction implements IStartupAction { + @Override + public void execute(@NonNull Activity activity) { + maybeShowNewsDialog(activity); + } +} diff --git a/app/src/foss/res/drawable/ic_donate_heart_foss.xml b/app/src/foss/res/drawable/ic_donate_heart_foss.xml new file mode 100644 index 00000000..4fe64169 --- /dev/null +++ b/app/src/foss/res/drawable/ic_donate_heart_foss.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/foss/res/values/strings_notranslate.xml b/app/src/foss/res/values/strings_notranslate.xml new file mode 100644 index 00000000..bbbc5fb3 --- /dev/null +++ b/app/src/foss/res/values/strings_notranslate.xml @@ -0,0 +1,4 @@ + + + https://liberapay.com/quite + diff --git a/app/src/foss/res/xml/list_drawer_headerdonate_foss.xml b/app/src/foss/res/xml/list_drawer_headerdonate_foss.xml new file mode 100644 index 00000000..10206025 --- /dev/null +++ b/app/src/foss/res/xml/list_drawer_headerdonate_foss.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/foss/res/xml/searchable.xml b/app/src/foss/res/xml/searchable.xml new file mode 100644 index 00000000..7b99ba77 --- /dev/null +++ b/app/src/foss/res/xml/searchable.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/goog/AndroidManifest.xml b/app/src/goog/AndroidManifest.xml new file mode 100644 index 00000000..c8bef4ca --- /dev/null +++ b/app/src/goog/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/app/src/goog/java/se/lublin/mumla/app/StartupAction.java b/app/src/goog/java/se/lublin/mumla/app/StartupAction.java new file mode 100644 index 00000000..24b70e48 --- /dev/null +++ b/app/src/goog/java/se/lublin/mumla/app/StartupAction.java @@ -0,0 +1,246 @@ +package se.lublin.mumla.app; + +import static com.android.billingclient.api.BillingClient.BillingResponseCode.OK; +import static com.android.billingclient.api.BillingClient.ProductType.INAPP; +import static com.android.billingclient.api.Purchase.PurchaseState.PENDING; +import static java.util.concurrent.TimeUnit.DAYS; +import static se.lublin.mumla.app.DialogUtils.maybeShowNewsDialog; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.PendingPurchasesParams; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.QueryProductDetailsParams; +import com.android.billingclient.api.QueryPurchasesParams; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.Collections; +import java.util.concurrent.ThreadLocalRandom; + +import se.lublin.mumla.R; + +public class StartupAction implements IStartupAction { + private static final String DONATION_PRODUCT_ID = "mumla_donation_1"; + private static final String PREF_STARTUP_COUNT = "startupCount"; + private static final String PREF_HAS_DONATED = "hasDonated"; + private static final String PREF_LAST_DONATION_VERIFY_TIMESTAMP = "lastDonationVerifyTimestamp"; + private static final long VERIFY_INTERVAL = DAYS.toMillis(2); + private BillingClient billingClient; + + private void showToast(Activity activity, String text) { + activity.runOnUiThread(() -> { + if (activity.isFinishing() || activity.isDestroyed()) { + return; + } + Toast.makeText(activity, text, Toast.LENGTH_LONG).show(); + }); + } + + @Override + public void execute(@NonNull Activity activity) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + + final int oldStartupCount = preferences.getInt(PREF_STARTUP_COUNT, 0); + final int startupCount = (oldStartupCount == Integer.MAX_VALUE) ? 1 : oldStartupCount + 1; + preferences.edit().putInt(PREF_STARTUP_COUNT, startupCount).apply(); + + if (maybeShowNewsDialog(activity)) { + // We showed some news so we don't show anything else + return; + } + + if (preferences.getBoolean(PREF_HAS_DONATED, false)) { + final long lastVerification = preferences.getLong(PREF_LAST_DONATION_VERIFY_TIMESTAMP, 0); + if ((System.currentTimeMillis() - lastVerification) > VERIFY_INTERVAL) { + verifyPurchase(activity, preferences); + } + return; + } + + billingClient = BillingClient.newBuilder(activity) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .setListener((result, purchases) -> { + // This is a callback for handling a new purchase + if ((result.getResponseCode() != OK) || (purchases == null)) { + return; + } + for (Purchase purchase : purchases) { + if (purchase.getProducts().contains(DONATION_PRODUCT_ID)) { + handleAndAckPurchase(activity, purchase, () -> { + preferences.edit().putBoolean(PREF_HAS_DONATED, true).apply(); + showToast(activity, activity.getString(R.string.donate_thanks_goog)); + }); + } + } + }) + .build(); + + billingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(@NonNull BillingResult result) { + if (result.getResponseCode() != OK) { + showToast(activity, String.format("Failed to setup billing: %s (code %d)", result.getDebugMessage(), result.getResponseCode())); + return; + } + + // Checking if already purchased, before possibly showing dialog + QueryPurchasesParams params = QueryPurchasesParams.newBuilder().setProductType(INAPP).build(); + billingClient.queryPurchasesAsync(params, (queryResult, purchases) -> { + if (queryResult.getResponseCode() != OK) { + showToast(activity, String.format("Failed to query purchases: %s (code %d)", queryResult.getDebugMessage(), queryResult.getResponseCode())); + return; + } + boolean foundAndHandled = false; + for (Purchase purchase : purchases) { + if (purchase.getProducts().contains(DONATION_PRODUCT_ID)) { + handleAndAckPurchase(activity, purchase, () -> { + preferences.edit().putBoolean(PREF_HAS_DONATED, true).apply(); + showToast(activity, activity.getString(R.string.donate_thanks_goog)); + }); + foundAndHandled = true; + break; + } + } + if (!foundAndHandled) { + if (startupCount % 5 == 1 || startupCount % 5 == 3) { + showDonationDialog(activity); + } + } + }); + } + + @Override + public void onBillingServiceDisconnected() { + // We just rely on our logic to be retried at app restart + } + }); + } + + private void verifyPurchase(Activity activity, SharedPreferences preferences) { + BillingClient client = BillingClient.newBuilder(activity) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .setListener((result, purchase) -> { /* NOP */ }) + .build(); + client.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(@NonNull BillingResult result) { + if (result.getResponseCode() != OK) { + return; + } + client.queryPurchasesAsync( + QueryPurchasesParams.newBuilder().setProductType(INAPP).build(), + (queryResult, purchases) -> { + if (queryResult.getResponseCode() != OK) { + return; + } + boolean stillFound = false; + for (Purchase purchase : purchases) { + if (purchase.getProducts().contains(DONATION_PRODUCT_ID) && purchase.isAcknowledged()) { + stillFound = true; + break; + } + } + if (!stillFound) { + showToast(activity, "Your donation was refunded or revoked"); + preferences.edit().putBoolean(PREF_HAS_DONATED, false).apply(); + } + preferences.edit().putLong(PREF_LAST_DONATION_VERIFY_TIMESTAMP, System.currentTimeMillis()).apply(); + client.endConnection(); + } + ); + } + + @Override + public void onBillingServiceDisconnected() { + } + }); + } + + private void showDonationDialog(Activity activity) { + activity.runOnUiThread(() -> { + if (activity.isFinishing() || activity.isDestroyed()) { + return; + } + int[] icons = { + R.drawable.ic_donate_heart_goog, + R.drawable.ic_donate_tag_faces_goog, + R.drawable.ic_donate_handshake_goog, + R.drawable.ic_donate_heart_smile_goog, + R.drawable.ic_donate_waving_hand_goog, + }; + int randomIconRes = icons[ThreadLocalRandom.current().nextInt(icons.length)]; + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.donate_dialog_title_goog) + .setMessage(R.string.donate_dialog_message_goog) + .setIcon(randomIconRes) + .setCancelable(false) + .setPositiveButton(R.string.donate_dialog_positivebutton_goog, (dialog, which) -> launchPurchaseFlow(activity)) + .setNegativeButton(R.string.donate_dialog_negativebutton_goog, (dialog, which) -> dialog.dismiss()) + .show(); + }); + } + + private void launchPurchaseFlow(Activity activity) { + if ((billingClient == null) || !billingClient.isReady()) { + showToast(activity, "Billing client is not ready"); + return; + } + + QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder() + .setProductList(Collections.singletonList( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(DONATION_PRODUCT_ID) + .setProductType(INAPP) + .build())) + .build(); + billingClient.queryProductDetailsAsync(params, (queryResult, productDetails) -> { + if ((queryResult.getResponseCode() != OK) || productDetails.isEmpty()) { + showToast(activity, String.format("Failed to query product details: %s (code %d)", queryResult.getDebugMessage(), queryResult.getResponseCode())); + return; + } + activity.runOnUiThread(() -> { + BillingFlowParams flowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(Collections.singletonList( + ProductDetailsParams.newBuilder() + .setProductDetails(productDetails.get(0)) + .build())) + .build(); + billingClient.launchBillingFlow(activity, flowParams); + }); + }); + } + + private void handleAndAckPurchase(Activity activity, Purchase purchase, @NonNull Runnable onAckSuccess) { + if (purchase.isAcknowledged()) { + onAckSuccess.run(); + return; + } + + if (purchase.getPurchaseState() == PENDING) { + showToast(activity, activity.getString(R.string.donate_purchase_pending_goog)); + return; + } + + AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.getPurchaseToken()) + .build(); + billingClient.acknowledgePurchase(params, (result) -> { + if (result.getResponseCode() != OK) { + showToast(activity, String.format("Failed to acknowledge purchase: %s (code %d)", result.getDebugMessage(), result.getResponseCode())); + return; + } + onAckSuccess.run(); + }); + } +} diff --git a/app/src/goog/res/drawable/ic_donate_handshake_goog.xml b/app/src/goog/res/drawable/ic_donate_handshake_goog.xml new file mode 100644 index 00000000..789e3f08 --- /dev/null +++ b/app/src/goog/res/drawable/ic_donate_handshake_goog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/goog/res/drawable/ic_donate_heart_goog.xml b/app/src/goog/res/drawable/ic_donate_heart_goog.xml new file mode 100644 index 00000000..4fe64169 --- /dev/null +++ b/app/src/goog/res/drawable/ic_donate_heart_goog.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/goog/res/drawable/ic_donate_heart_smile_goog.xml b/app/src/goog/res/drawable/ic_donate_heart_smile_goog.xml new file mode 100644 index 00000000..aee7794f --- /dev/null +++ b/app/src/goog/res/drawable/ic_donate_heart_smile_goog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/goog/res/drawable/ic_donate_tag_faces_goog.xml b/app/src/goog/res/drawable/ic_donate_tag_faces_goog.xml new file mode 100644 index 00000000..3e8b1971 --- /dev/null +++ b/app/src/goog/res/drawable/ic_donate_tag_faces_goog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/goog/res/drawable/ic_donate_waving_hand_goog.xml b/app/src/goog/res/drawable/ic_donate_waving_hand_goog.xml new file mode 100644 index 00000000..5eec21da --- /dev/null +++ b/app/src/goog/res/drawable/ic_donate_waving_hand_goog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/goog/res/xml/searchable.xml b/app/src/goog/res/xml/searchable.xml new file mode 100644 index 00000000..85b6ff5e --- /dev/null +++ b/app/src/goog/res/xml/searchable.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..544b070c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/radio/default.json b/app/src/main/assets/radio/default.json new file mode 100644 index 00000000..318089f4 --- /dev/null +++ b/app/src/main/assets/radio/default.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 3, + "configVersion": 6, + "deviceId": "*", + "service": { + "name": "Minimum Public PTT" + }, + "radio": { + "defaultChannel": "main", + "autoConnect": false, + "autoReconnect": true + }, + "connections": { + "public-main": { + "name": "Minimum Public PTT", + "host": "voice.example.invalid", + "port": 64738, + "username": "MINIMUM", + "autoTrustServerCertificate": true + } + }, + "channels": [ + { + "id": "main", + "label": "ห้องหลัก", + "alias": "MAIN", + "connectionId": "public-main", + "path": "/PUBLIC/MAIN", + "presetKey": "P1", + "access": { + "mode": "none" + } + } + ], + "ui": { + "profile": "small-radio", + "language": "th", + "showChat": false, + "showUserList": false, + "showChannelTree": false, + "allowExit": false, + "voicePrompt": true + }, + "ptt": { + "maximumTxSeconds": 120, + "allowScreenOff": true, + "releaseOnNetworkLoss": true + }, + "tracking": { + "enabled": false, + "pttTriggered": true, + "aprs": { + "enabled": false, + "sourceCallsign": "E25FGL", + "host": "ametx.com", + "port": 8888 + } + }, + "hardware": { + "profile": "generic-radio", + "pttKeyCode": 0, + "pttScanCode": 0, + "pttKeyCodes": [], + "locationTrackingSupported": false, + "p1KeyCode": 0, + "p2KeyCode": 0, + "p3KeyCode": 0 + }, + "update": { + "checkOnBoot": true, + "checkIntervalMinutes": 360, + "applyMode": "when-idle" + } +} diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..016d4951 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/se/lublin/mumla/Settings.java b/app/src/main/java/se/lublin/mumla/Settings.java new file mode 100644 index 00000000..5904c8f2 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/Settings.java @@ -0,0 +1,426 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.Gravity; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import se.lublin.humla.Constants; + +/** + * Singleton settings class for universal access to the app's preferences. + * + * @author morlunk + */ +public class Settings { + private static final String TAG = Settings.class.getName(); + + public static final String PREF_INPUT_METHOD = "audioInputMethod"; + public static final Set ARRAY_INPUT_METHODS; + /** Voice activity transmits depending on the amplitude of user input. */ + public static final String ARRAY_INPUT_METHOD_VOICE = "voiceActivity"; + /** Push to talk transmits on command. */ + public static final String ARRAY_INPUT_METHOD_PTT = "ptt"; + /** Continuous transmits always. */ + public static final String ARRAY_INPUT_METHOD_CONTINUOUS = "continuous"; + + // NOTE: When changing DEFAULTs, the default value in the corresponding + // widget in settings_PAGE.xml must also be changed. It doesn't pick this + // up itself... + + public static final String PREF_THRESHOLD = "vadThreshold"; + public static final int DEFAULT_THRESHOLD = 50; + + public static final String PREF_PUSH_KEY = "talkKey"; + public static final Integer DEFAULT_PUSH_KEY = -1; + + public static final String PREF_HOT_CORNER_KEY = "hotCorner"; + public static final String ARRAY_HOT_CORNER_NONE = "none"; + public static final String ARRAY_HOT_CORNER_TOP_LEFT = "topLeft"; + public static final String ARRAY_HOT_CORNER_BOTTOM_LEFT = "bottomLeft"; + public static final String ARRAY_HOT_CORNER_TOP_RIGHT = "topRight"; + public static final String ARRAY_HOT_CORNER_BOTTOM_RIGHT = "bottomRight"; + public static final String DEFAULT_HOT_CORNER = ARRAY_HOT_CORNER_NONE; + + public static final String PREF_PUSH_BUTTON_HIDE_KEY = "hidePtt"; + public static final Boolean DEFAULT_PUSH_BUTTON_HIDE = false; + + public static final String PREF_PTT_TOGGLE = "togglePtt"; + public static final Boolean DEFAULT_PTT_TOGGLE = false; + + public static final String PREF_INPUT_RATE = "input_quality"; + public static final String DEFAULT_RATE = "48000"; + + public static final String PREF_INPUT_QUALITY = "input_bitrate"; + public static final int DEFAULT_INPUT_QUALITY = 40000; + + public static final String PREF_AMPLITUDE_BOOST = "inputVolume"; + public static final Integer DEFAULT_AMPLITUDE_BOOST = 100; + + public static final String PREF_CHAT_NOTIFY = "chatNotify"; + public static final Boolean DEFAULT_CHAT_NOTIFY = true; + + public static final String PREF_USE_TTS = "useTts"; + public static final Boolean DEFAULT_USE_TTS = true; + + public static final String PREF_SHORT_TTS_MESSAGES = "shortTtsMessages"; + public static final boolean DEFAULT_SHORT_TTS_MESSAGES = false; + + public static final String PREF_AUTO_RECONNECT = "autoReconnect"; + public static final Boolean DEFAULT_AUTO_RECONNECT = true; + + /** Launch the client automatically after BOOT_COMPLETED. */ + public static final String PREF_AUTO_START = "autoStart"; + public static final Boolean DEFAULT_AUTO_START = true; + + public static final String PREF_THEME = "theme"; + public static final String PREF_LANGUAGE = "language"; + + public static final String PREF_PTT_BUTTON_HEIGHT = "pttButtonHeight"; + public static final int DEFAULT_PTT_BUTTON_HEIGHT = 150; + + /** + * The DB identifier for the default certificate. + * @see se.lublin.mumla.db.DatabaseCertificate + */ + public static final String PREF_CERT_ID = "certificateId"; + + public static final String PREF_DEFAULT_USERNAME = "defaultUsername"; + public static final String DEFAULT_DEFAULT_USERNAME = "Mumla_User"; // funny var name + + public static final String PREF_FORCE_TCP = "forceTcp"; + public static final Boolean DEFAULT_FORCE_TCP = false; + + public static final String PREF_USE_TOR = "useTor"; + public static final Boolean DEFAULT_USE_TOR = false; + + public static final String PREF_DISABLE_OPUS = "disableOpus"; + public static final Boolean DEFAULT_DISABLE_OPUS = false; + + public static final String PREF_MUTED = "muted"; + public static final Boolean DEFAULT_MUTED = false; + + public static final String PREF_DEAFENED = "deafened"; + public static final Boolean DEFAULT_DEAFENED = false; + + public static final String PREF_FIRST_RUN = "firstRun"; + public static final Boolean DEFAULT_FIRST_RUN = true; + + public static final String PREF_LOAD_IMAGES = "load_images"; + public static final boolean DEFAULT_LOAD_IMAGES = true; + + public static final String PREF_FRAMES_PER_PACKET = "audio_per_packet"; + public static final String DEFAULT_FRAMES_PER_PACKET = "2"; + + public static final String PREF_HALF_DUPLEX = "half_duplex"; + public static final boolean DEFAULT_HALF_DUPLEX = false; + + public static final String PREF_HANDSET_MODE = "handset_mode"; + public static final boolean DEFAULT_HANDSET_MODE = false; + + public static final String PREF_PTT_SOUND = "ptt_sound"; + public static final boolean DEFAULT_PTT_SOUND = false; + + public static final String PREF_PREPROCESSOR_ENABLED = "preprocessor_enabled"; + public static final boolean DEFAULT_PREPROCESSOR_ENABLED = true; + + public static final String PREF_ECHO_CANCELLATION_METHOD = "echo_cancellation_method"; + public static final String DEFAULT_ECHO_CANCELLATION_METHOD = "none"; + + public static final String PREF_STAY_AWAKE = "stay_awake"; + public static final boolean DEFAULT_STAY_AWAKE = false; + + public static final String PREF_SHOW_USER_COUNT = "show_user_count"; + public static final boolean DEFAULT_SHOW_USER_COUNT = false; + + public static final String PREF_START_UP_IN_PINNED_MODE = "startUpInPinnedMode"; + public static final boolean DEFAULT_START_UP_IN_PINNED_MODE = false; + + public static final String PREF_NEWS_SHOWN_VERSIONS = "newsShownVersions"; + + static { + ARRAY_INPUT_METHODS = new HashSet(); + ARRAY_INPUT_METHODS.add(ARRAY_INPUT_METHOD_VOICE); + ARRAY_INPUT_METHODS.add(ARRAY_INPUT_METHOD_PTT); + ARRAY_INPUT_METHODS.add(ARRAY_INPUT_METHOD_CONTINUOUS); + } + + private final SharedPreferences preferences; + + public static Settings getInstance(Context context) { + return new Settings(context); + } + + private Settings(Context ctx) { + preferences = PreferenceManager.getDefaultSharedPreferences(ctx); + } + + public String getInputMethod() { + String method = preferences.getString(PREF_INPUT_METHOD, ARRAY_INPUT_METHOD_VOICE); + if(!ARRAY_INPUT_METHODS.contains(method)) { + // Set default method for users who used to use handset mode before removal. + method = ARRAY_INPUT_METHOD_VOICE; + } + return method; + } + + /** + * Converts the preference input method value to the one used to connect to a server via Humla. + * @return An input method value used to instantiate a Humla service. + */ + public int getHumlaInputMethod() { + String inputMethod = getInputMethod(); + if (ARRAY_INPUT_METHOD_VOICE.equals(inputMethod)) { + return Constants.TRANSMIT_VOICE_ACTIVITY; + } else if (ARRAY_INPUT_METHOD_PTT.equals(inputMethod)) { + return Constants.TRANSMIT_PUSH_TO_TALK; + } else if (ARRAY_INPUT_METHOD_CONTINUOUS.equals(inputMethod)) { + return Constants.TRANSMIT_CONTINUOUS; + } + throw new RuntimeException("Could not convert input method '" + inputMethod + "' to a Humla input method id!"); + } + + public void setInputMethod(String inputMethod) { + if(ARRAY_INPUT_METHOD_VOICE.equals(inputMethod) || + ARRAY_INPUT_METHOD_PTT.equals(inputMethod) || + ARRAY_INPUT_METHOD_CONTINUOUS.equals(inputMethod)) { + preferences.edit().putString(PREF_INPUT_METHOD, inputMethod).apply(); + } else { + throw new RuntimeException("Invalid input method " + inputMethod); + } + } + + public int getInputSampleRate() { + return Integer.parseInt(preferences.getString(Settings.PREF_INPUT_RATE, DEFAULT_RATE)); + } + + public int getInputQuality() { + return preferences.getInt(Settings.PREF_INPUT_QUALITY, DEFAULT_INPUT_QUALITY); + } + + public float getAmplitudeBoostMultiplier() { + return (float)preferences.getInt(Settings.PREF_AMPLITUDE_BOOST, DEFAULT_AMPLITUDE_BOOST)/100; + } + + public float getDetectionThreshold() { + return (float)preferences.getInt(PREF_THRESHOLD, DEFAULT_THRESHOLD)/100; + } + + public int getPushToTalkKey() { + return preferences.getInt(PREF_PUSH_KEY, DEFAULT_PUSH_KEY); + } + + public String getHotCorner() { + return preferences.getString(PREF_HOT_CORNER_KEY, DEFAULT_HOT_CORNER); + } + + /** + * Returns whether or not the hot corner is enabled. + * @return true if a hot corner should be shown. + */ + public boolean isHotCornerEnabled() { + return !ARRAY_HOT_CORNER_NONE.equals(preferences.getString(PREF_HOT_CORNER_KEY, DEFAULT_HOT_CORNER)); + } + + /** + * Returns the view gravity of the hot corner, or 0 if hot corner is disabled. + * @return A {@link android.view.Gravity} value, or 0 if disabled. + */ + public int getHotCornerGravity() { + String hc = getHotCorner(); + if(ARRAY_HOT_CORNER_BOTTOM_LEFT.equals(hc)) { + return Gravity.LEFT | Gravity.BOTTOM; + } else if(ARRAY_HOT_CORNER_BOTTOM_RIGHT.equals(hc)) { + return Gravity.RIGHT | Gravity.BOTTOM; + } else if(ARRAY_HOT_CORNER_TOP_LEFT.equals(hc)) { + return Gravity.LEFT | Gravity.TOP; + } else if(ARRAY_HOT_CORNER_TOP_RIGHT.equals(hc)) { + return Gravity.RIGHT | Gravity.TOP; + } + return 0; + } + + + /* @return the height of PTT button */ + public int getPTTButtonHeight() { + return preferences.getInt(Settings.PREF_PTT_BUTTON_HEIGHT, DEFAULT_PTT_BUTTON_HEIGHT); + } + + /** + * Returns a database identifier for the default certificate, or a negative number if there is + * no default certificate set. + * @return The default certificate's ID, or a negative integer if not set. + */ + public long getDefaultCertificate() { + return preferences.getLong(PREF_CERT_ID, -1); + } + + public String getDefaultUsername() { + return preferences.getString(PREF_DEFAULT_USERNAME, DEFAULT_DEFAULT_USERNAME); + } + + public boolean isPushToTalkToggle() { + return preferences.getBoolean(PREF_PTT_TOGGLE, DEFAULT_PTT_TOGGLE); + } + + public boolean isPushToTalkButtonShown() { + return !preferences.getBoolean(PREF_PUSH_BUTTON_HIDE_KEY, DEFAULT_PUSH_BUTTON_HIDE); + } + + public boolean isChatNotifyEnabled() { + return preferences.getBoolean(PREF_CHAT_NOTIFY, DEFAULT_CHAT_NOTIFY); + } + + public boolean isTextToSpeechEnabled() { + return preferences.getBoolean(PREF_USE_TTS, DEFAULT_USE_TTS); + } + + public boolean isShortTextToSpeechMessagesEnabled() { + return preferences.getBoolean(PREF_SHORT_TTS_MESSAGES, DEFAULT_SHORT_TTS_MESSAGES); + } + + public boolean isAutoReconnectEnabled() { + return preferences.getBoolean(PREF_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT); + } + + public boolean isAutoStartEnabled() { + return preferences.getBoolean(PREF_AUTO_START, DEFAULT_AUTO_START); + } + + public boolean isTcpForced() { + return preferences.getBoolean(PREF_FORCE_TCP, DEFAULT_FORCE_TCP); + } + + public boolean isOpusDisabled() { + return preferences.getBoolean(PREF_DISABLE_OPUS, DEFAULT_DISABLE_OPUS); + } + + public boolean isTorEnabled() { + return preferences.getBoolean(PREF_USE_TOR, DEFAULT_USE_TOR); + } + public void disableTor() { + preferences.edit().putBoolean(PREF_USE_TOR, false).apply(); + } + + public boolean isMuted() { + return preferences.getBoolean(PREF_MUTED, DEFAULT_MUTED); + } + + public boolean isDeafened() { + return preferences.getBoolean(PREF_DEAFENED, DEFAULT_DEAFENED); + } + + public boolean isFirstRun() { + return preferences.getBoolean(PREF_FIRST_RUN, DEFAULT_FIRST_RUN); + } + + public boolean shouldLoadExternalImages() { + return preferences.getBoolean(PREF_LOAD_IMAGES, DEFAULT_LOAD_IMAGES); + } + + public void setMutedAndDeafened(boolean muted, boolean deafened) { + preferences.edit().putBoolean(PREF_MUTED, muted || deafened).putBoolean(PREF_DEAFENED, deafened).apply(); + } + + public void setFirstRun(boolean run) { + preferences.edit().putBoolean(PREF_FIRST_RUN, run).apply(); + } + + public int getFramesPerPacket() { + return Integer.parseInt(preferences.getString(PREF_FRAMES_PER_PACKET, DEFAULT_FRAMES_PER_PACKET)); + } + + public boolean isHalfDuplex() { + return preferences.getBoolean(PREF_HALF_DUPLEX, DEFAULT_HALF_DUPLEX); + } + + public boolean isHandsetMode() { + return preferences.getBoolean(PREF_HANDSET_MODE, DEFAULT_HANDSET_MODE); + } + + public boolean isPttSoundEnabled() { + return preferences.getBoolean(PREF_PTT_SOUND, DEFAULT_PTT_SOUND); + } + + public boolean isPreprocessorEnabled() { + return preferences.getBoolean(PREF_PREPROCESSOR_ENABLED, DEFAULT_PREPROCESSOR_ENABLED); + } + + public String getEchoCancellationMethod() { + return preferences.getString(PREF_ECHO_CANCELLATION_METHOD, DEFAULT_ECHO_CANCELLATION_METHOD); + } + + public boolean shouldStayAwake() { + return preferences.getBoolean(PREF_STAY_AWAKE, DEFAULT_STAY_AWAKE); + } + + public void setDefaultCertificateId(long defaultCertificateId) { + preferences.edit().putLong(PREF_CERT_ID, defaultCertificateId).apply(); + } + + public void disableCertificate() { + preferences.edit().putLong(PREF_CERT_ID, -1).apply(); + } + + public boolean isUsingCertificate() { + return getDefaultCertificate() >= 0; + } + + /** + * @return true if the user count should be shown next to channels. + */ + public boolean shouldShowUserCount() { + return preferences.getBoolean(PREF_SHOW_USER_COUNT, DEFAULT_SHOW_USER_COUNT); + } + + public boolean shouldStartUpInPinnedMode() { + return preferences.getBoolean(PREF_START_UP_IN_PINNED_MODE, DEFAULT_START_UP_IN_PINNED_MODE); + } + + public Set getNewsShownVersions() { + return preferences.getStringSet(PREF_NEWS_SHOWN_VERSIONS, new HashSet<>()); + } + + public void addNewsShownVersions(@NonNull List versions) { + // Making a copy; getStringSet docs states that the returned set must not be modified + Set shownVersions = new HashSet<>(preferences.getStringSet(PREF_NEWS_SHOWN_VERSIONS, new HashSet<>())); + for (Iterator it = versions.iterator(); it.hasNext(); ) { + String s = it.next(); + if (s == null || s.isEmpty()) { + it.remove(); + } + } + if (shownVersions.addAll(versions)) { + preferences.edit().putStringSet(PREF_NEWS_SHOWN_VERSIONS, shownVersions).apply(); + } + } + + public void resetNewsShownVersion() { + preferences.edit().putStringSet(PREF_NEWS_SHOWN_VERSIONS, new HashSet<>()).apply(); + } + +} diff --git a/app/src/main/java/se/lublin/mumla/app/DialogUtils.java b/app/src/main/java/se/lublin/mumla/app/DialogUtils.java new file mode 100644 index 00000000..96730304 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/app/DialogUtils.java @@ -0,0 +1,137 @@ +package se.lublin.mumla.app; + +import static androidx.core.text.HtmlCompat.fromHtml; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import se.lublin.mumla.BuildConfig; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; + +public final class DialogUtils { + // Preventing instantiation + private DialogUtils() { + } + + private static final Map NEWS_ITEMS; + private static final List RELEVANT_VERSIONS; + + static { + // Using LinkedHasMap because order matters + NEWS_ITEMS = new LinkedHashMap<>(); + // These *must* be added in ascending semver order (1.2.3, 1.10.1, ...) + NEWS_ITEMS.put("3.7.0", R.string.app_news_items_v3_7_0); + NEWS_ITEMS.put("3.7.1", R.string.app_news_items_v3_7_1); + NEWS_ITEMS.put("3.7.2", R.string.app_news_items_v3_7_2); + NEWS_ITEMS.put("3.7.3", R.string.app_news_items_v3_7_3); + RELEVANT_VERSIONS = new ArrayList<>(); + for (String candidate : NEWS_ITEMS.keySet()) { + // Relevant as long as candidate-version <= build-version; otherwise break + if (new Version(candidate).compareTo(new Version(BuildConfig.VERSIONTAG)) > 0) { + break; + } + RELEVANT_VERSIONS.add(candidate); + } + } + + public static boolean maybeShowNewsDialog(Context context) { + List toShow = new ArrayList<>(); + Set shown = Settings.getInstance(context).getNewsShownVersions(); + for (String version : RELEVANT_VERSIONS) { + if (!shown.contains(version)) { + toShow.add(version); + } + } + + if (toShow.isEmpty()) { + return false; + } + + showNewsDialogForVersions(context, toShow, true); + return true; + } + + public static void showAllNewsDialog(Context context) { + showNewsDialogForVersions(context, RELEVANT_VERSIONS, false); + } + + private static void showNewsDialogForVersions(Context context, List versions, boolean markShown) { + StringBuilder sb = new StringBuilder(); + for (int i = versions.size() - 1; i >= 0; i--) { + String version = versions.get(i); + Integer resId = NEWS_ITEMS.get(version); + if (resId == null) { + continue; + } + sb.append(String.format("%s %s
", context.getString(R.string.version), version)); + sb.append(context.getString(resId).replace("\n", "
")); + sb.append("
"); + } + + if (markShown) { + sb.append(String.format("%s", context.getString(R.string.app_news_footer_on_startup))); + } + + LayoutInflater inflater = LayoutInflater.from(context); + View dialogView = inflater.inflate(R.layout.dialog_news, null); + TextView newsTextView = dialogView.findViewById(R.id.news_text_view); + newsTextView.setText(fromHtml(sb.toString(), 0)); + + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.app_news) + .setView(dialogView) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (markShown) { + Settings.getInstance(context).addNewsShownVersions(versions); + } + dialog.dismiss(); + }) + .show(); + } + + private static class Version implements Comparable { + final int major; + final int minor; + final int patch; + + Version(String versionString) { + // Removing any "-suffix" + String[] parts = versionString.split("-")[0].split("\\."); + int maj = 0, min = 0, pat = 0; + try { + if (parts.length > 0) maj = Integer.parseInt(parts[0]); + if (parts.length > 1) min = Integer.parseInt(parts[1]); + if (parts.length > 2) pat = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + Log.d("DialogUtils", "Failed to parse version string: " + versionString, e); + } + this.major = maj; + this.minor = min; + this.patch = pat; + } + + @Override + public int compareTo(Version other) { + if (this.major != other.major) { + return Integer.compare(this.major, other.major); + } + if (this.minor != other.minor) { + return Integer.compare(this.minor, other.minor); + } + return Integer.compare(this.patch, other.patch); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/app/DrawerAdapter.java b/app/src/main/java/se/lublin/mumla/app/DrawerAdapter.java new file mode 100644 index 00000000..ccae0d2c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/app/DrawerAdapter.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.app; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import se.lublin.mumla.R; + +/** + * Created by andrew on 01/08/13. + */ +public class DrawerAdapter extends ArrayAdapter { + + /** + * Provides context for the drawer module. + */ + public interface DrawerDataProvider { + + /** + * @return true if connected, false otherwise. + */ + public boolean isConnected(); + /** + * @return The name of the connected server. If not connected, then null. + */ + public String getConnectedServerName(); + } + + // Drawer rows, integer value is id + public static final int HEADER_CONNECTED_SERVER = 0; + public static final int ITEM_SERVER = 1; + public static final int ITEM_PINNED_CHANNELS = 2; + public static final int ITEM_INFO = 3; + public static final int ITEM_ACCESS_TOKENS = 4; + public static final int HEADER_SERVERS = 5; + public static final int ITEM_FAVOURITES = 6; +// public static final int ITEM_LAN = 7; + public static final int ITEM_PUBLIC = 8; + public static final int HEADER_GENERAL = 9; + public static final int ITEM_SETTINGS = 10; + + private static final int HEADER_TYPE = 0; + private static final int ITEM_TYPE = 1; + + // TODO clean this up. + + public static class DrawerRow { + int id; + String title; + + private DrawerRow(int id, String title) { + this.id = id; + this.title = title; + } + } + + public static class DrawerHeader extends DrawerRow { + + public DrawerHeader(int id, String title) { + super(id, title); + } + } + + public static class DrawerItem extends DrawerRow { + int icon; + + public DrawerItem(int id, String title, int icon) { + super(id, title); + this.icon = icon; + } + } + + private DrawerDataProvider mProvider; + + public DrawerAdapter(Context context, DrawerDataProvider provider) { + super(context, 0); + mProvider = provider; + add(new DrawerAdapter.DrawerHeader(HEADER_CONNECTED_SERVER, context.getString(R.string.drawer_not_connected))); + add(new DrawerAdapter.DrawerItem(ITEM_SERVER, context.getString(R.string.drawer_server), R.drawable.ic_action_channels)); + add(new DrawerAdapter.DrawerItem(ITEM_PINNED_CHANNELS, context.getString(R.string.drawer_pinned), R.drawable.ic_action_comment)); + add(new DrawerAdapter.DrawerItem(ITEM_INFO, context.getString(R.string.information), R.drawable.ic_action_info_dark)); + add(new DrawerAdapter.DrawerItem(ITEM_ACCESS_TOKENS, context.getString(R.string.drawer_tokens), R.drawable.ic_action_save)); + add(new DrawerAdapter.DrawerHeader(HEADER_SERVERS, context.getString(R.string.drawer_header_servers))); + add(new DrawerAdapter.DrawerItem(ITEM_FAVOURITES, context.getString(R.string.drawer_favorites), R.drawable.ic_action_favourite_on)); +// add(new DrawerAdapter.DrawerItem(ITEM_LAN, context.getString(R.string.drawer_lan), R.drawable.ic_action_fullscreen)); // Coming soon, TODO + add(new DrawerAdapter.DrawerItem(ITEM_PUBLIC, context.getString(R.string.drawer_public), R.drawable.ic_action_search)); + add(new DrawerAdapter.DrawerHeader(HEADER_GENERAL, context.getString(R.string.general))); + add(new DrawerAdapter.DrawerItem(ITEM_SETTINGS, context.getString(R.string.action_settings), R.drawable.ic_action_settings)); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = convertView; + int viewType = getItemViewType(position); + if(v == null) { + if(viewType == HEADER_TYPE) + v = LayoutInflater.from(getContext()).inflate(R.layout.list_drawer_header, parent, false); + else if(viewType == ITEM_TYPE) + v = LayoutInflater.from(getContext()).inflate(R.layout.list_drawer_item, parent, false); + + } + + if(viewType == HEADER_TYPE) { + DrawerHeader header = (DrawerHeader) getItem(position); + TextView title = (TextView) v.findViewById(R.id.drawer_header_title); + + switch((int) getItemId(position)) { + case HEADER_CONNECTED_SERVER: + if (mProvider.isConnected()) { + title.setText(mProvider.getConnectedServerName()); + } else { + title.setText(header.title); + } + break; + default: + title.setText(header.title); + break; + } + } else if(viewType == ITEM_TYPE) { + DrawerItem item = (DrawerItem) getItem(position); + TextView title = (TextView) v.findViewById(R.id.drawer_item_title); + ImageView icon = (ImageView) v.findViewById(R.id.drawer_item_icon); + title.setText(item.title); + icon.setImageResource(item.icon); + + boolean enabled = isEnabled(position); + + // Set text and icon color+alpha based on enabled/disabled state + int textColor = title.getCurrentTextColor(); + textColor &= 0x00FFFFFF; // Clear alpha bits + textColor |= enabled ? 0xFF000000 : 0x55000000; // Set alpha bits depending on whether the state is enabled or disabled + title.setTextColor(textColor); + icon.setColorFilter(textColor, PorterDuff.Mode.MULTIPLY); + + } + + return v; + } + + @Override + public long getItemId(int position) { + return getItem(position).id; + } + + public DrawerRow getItemWithId(int id) { + for(int x=0;x. + */ + +package se.lublin.mumla.app; + +import static java.util.Objects.requireNonNull; + +import android.Manifest; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.text.InputType; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; +import androidx.preference.PreferenceManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.jetbrains.annotations.NotNull; +import org.spongycastle.util.encoders.Hex; + +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.Socket; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import info.guardianproject.netcipher.proxy.OrbotHelper; +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.Server; +import se.lublin.humla.net.HumlaConnection; +import se.lublin.humla.protobuf.Mumble; +import se.lublin.humla.util.HumlaException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.humla.util.MumbleURLParser; +import se.lublin.mumla.BuildConfig; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.channel.AccessTokenFragment; +import se.lublin.mumla.channel.ChannelFragment; +import se.lublin.mumla.channel.ServerInfoFragment; +import se.lublin.mumla.db.DatabaseProvider; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; +import se.lublin.mumla.db.PublicServer; +import se.lublin.mumla.preference.MumlaCertificateGenerateTask; +import se.lublin.mumla.preference.SettingsActivity; +import se.lublin.mumla.servers.FavouriteServerListFragment; +import se.lublin.mumla.servers.PublicServerListFragment; +import se.lublin.mumla.servers.ServerEditFragment; +import se.lublin.mumla.service.IMumlaService; +import se.lublin.mumla.service.MumlaService; +import se.lublin.mumla.radio.RadioPttKeyManager; +import se.lublin.mumla.util.HumlaServiceFragment; +import se.lublin.mumla.util.HumlaServiceProvider; +import se.lublin.mumla.util.MumlaTrustStore; + +public class MumlaActivity extends AppCompatActivity implements ListView.OnItemClickListener, + FavouriteServerListFragment.ServerConnectHandler, HumlaServiceProvider, DatabaseProvider, + SharedPreferences.OnSharedPreferenceChangeListener, DrawerAdapter.DrawerDataProvider, + ServerEditFragment.ServerEditListener { + private static final String TAG = MumlaActivity.class.getName(); + + /** + * If specified, the provided integer drawer fragment ID is shown when the activity is created. + */ + public static final String EXTRA_DRAWER_FRAGMENT = "drawer_fragment"; + + private IMumlaService mService; + private MumlaDatabase mDatabase; + private Settings mSettings; + + private ActionBarDrawerToggle mDrawerToggle; + private DrawerLayout mDrawerLayout; + private DrawerAdapter mDrawerAdapter; + + private static final int PERMISSIONS_REQUEST_RECORD_AUDIO = 1; + private static final int PERMISSIONS_REQUEST_POST_NOTIFICATIONS = 2; + private Server mServerPendingPerm = null; + private boolean mPermPostNotificationsAsked = false; + + private AlertDialog mConnectingDialog; + private AlertDialog mErrorDialog; + + /** + * List of fragments to be notified about service state changes. + */ + private final List mServiceFragments = new ArrayList(); + + private final ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mService = ((MumlaService.MumlaBinder) service).getService(); + mService.setSuppressNotifications(true); + mService.registerObserver(mObserver); + mService.clearChatNotifications(); // Clear chat notifications on resume. + mDrawerAdapter.notifyDataSetChanged(); + + for (HumlaServiceFragment fragment : mServiceFragments) + fragment.setServiceBound(true); + + // Re-show server list if we're showing a fragment that depends on the service. + if (getSupportFragmentManager().findFragmentById(R.id.content_frame) instanceof HumlaServiceFragment && + !mService.isConnected()) { + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + } + updateConnectionState(getService()); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + private final HumlaObserver mObserver = new HumlaObserver() { + @Override + public void onConnected() { + if (mSettings.shouldStartUpInPinnedMode()) { + loadDrawerFragment(DrawerAdapter.ITEM_PINNED_CHANNELS); + } else { + loadDrawerFragment(DrawerAdapter.ITEM_SERVER); + } + + mDrawerAdapter.notifyDataSetChanged(); + supportInvalidateOptionsMenu(); + + updateConnectionState(getService()); + } + + @Override + public void onConnecting() { + updateConnectionState(getService()); + } + + @Override + public void onDisconnected(HumlaException e) { + // Re-show server list if we're showing a fragment that depends on the service. + if (getSupportFragmentManager().findFragmentById(R.id.content_frame) instanceof HumlaServiceFragment) { + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + } + mDrawerAdapter.notifyDataSetChanged(); + supportInvalidateOptionsMenu(); + + updateConnectionState(getService()); + } + + @Override + public void onTLSHandshakeFailed(X509Certificate[] chain) { + if (chain.length == 0) { + return; + } + final Server lastServer = getService().getTargetServer(); + try { + final X509Certificate x509 = chain[0]; + View layout = getLayoutInflater().inflate(R.layout.certificate_info, null); + TextView textView = layout.findViewById(R.id.certificate_info_text); + try { + MessageDigest digest1 = MessageDigest.getInstance("SHA-1"); + MessageDigest digest2 = MessageDigest.getInstance("SHA-256"); + String hexDigest1 = new String(Hex.encode(digest1.digest(x509.getEncoded()))) + .replaceAll("(..)", "$1:"); + String hexDigest2 = new String(Hex.encode(digest2.digest(x509.getEncoded()))) + .replaceAll("(..)", "$1:"); + + textView.setText(getString(R.string.certificate_info, + x509.getSubjectDN().getName(), + x509.getNotBefore().toString(), + x509.getNotAfter().toString(), + hexDigest1.substring(0, hexDigest1.length() - 1), + hexDigest2.substring(0, hexDigest2.length() - 1))); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + textView.setText(x509.toString()); + } + new MaterialAlertDialogBuilder(MumlaActivity.this) + .setTitle(R.string.untrusted_certificate) + .setView(layout) + .setPositiveButton(R.string.allow, (dialog, which) -> { + // Try to add to trust store + try { + String alias = lastServer.getHost(); + KeyStore trustStore = MumlaTrustStore.getTrustStore(MumlaActivity.this); + trustStore.setCertificateEntry(alias, x509); + MumlaTrustStore.saveTrustStore(MumlaActivity.this, trustStore); + Toast.makeText(MumlaActivity.this, R.string.trust_added, Toast.LENGTH_LONG).show(); + connectToServer(lastServer); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(MumlaActivity.this, R.string.trust_add_failed, Toast.LENGTH_LONG).show(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } catch (CertificateException e) { + e.printStackTrace(); + } + } + + @Override + public void onPermissionDenied(String reason) { + new MaterialAlertDialogBuilder(MumlaActivity.this) + .setTitle(R.string.perm_denied) + .setMessage(reason) + .show(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + mSettings = Settings.getInstance(this); + + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (mService != null && mService.isConnected()) { + new MaterialAlertDialogBuilder(MumlaActivity.this) + .setMessage(getString(R.string.disconnectSure, mService.getTargetServer().getName())) + .setPositiveButton(R.string.confirm, (dialog, which) -> { + mService.disconnect(); + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + setEnabled(true); + } + } + }); + + setStayAwake(mSettings.shouldStayAwake()); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + preferences.registerOnSharedPreferenceChangeListener(this); + + mDatabase = new MumlaSQLiteDatabase(this); // TODO add support for cloud storage + mDatabase.open(); + + mDrawerLayout = findViewById(R.id.drawer_layout); + ListView mDrawerList = findViewById(R.id.left_drawer); + + View headerView = getLayoutInflater().inflate(R.layout.list_drawer_headerlogo, mDrawerList, false); + mDrawerList.addHeaderView(headerView, null, false); + + if (BuildConfig.FLAVOR.equals("foss")) { + final int layoutResId = getResources().getIdentifier("list_drawer_headerdonate_foss", "xml", getPackageName()); + final int stringResId = getResources().getIdentifier("donate_link_foss", "string", getPackageName()); + if ((layoutResId != 0) && (stringResId != 0)) { + View footerView = getLayoutInflater().inflate(layoutResId, mDrawerList, false); + mDrawerList.addHeaderView(footerView, null, true); + footerView.setOnClickListener(v -> { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(stringResId))); + startActivity(intent); + mDrawerLayout.closeDrawers(); + }); + } + } + + mDrawerList.setOnItemClickListener(this); + mDrawerAdapter = new DrawerAdapter(this, this); + mDrawerList.setAdapter(mDrawerAdapter); + mDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, toolbar, R.string.drawer_open, R.string.drawer_close) { + @Override + public void onDrawerClosed(View drawerView) { + supportInvalidateOptionsMenu(); + } + + @Override + public void onDrawerStateChanged(int newState) { + super.onDrawerStateChanged(newState); + // Prevent push to talk from getting stuck on when the drawer is opened. + if (getService() != null && getService().isConnected()) { + IHumlaSession session = getService().HumlaSession(); + if (session.isTalking() && !mSettings.isPushToTalkToggle()) { + session.setTalkingState(false); + } + } + } + + @Override + public void onDrawerOpened(View drawerView) { + supportInvalidateOptionsMenu(); + } + }; + + mDrawerLayout.setDrawerListener(mDrawerToggle); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setHomeButtonEnabled(true); + + if (savedInstanceState == null) { + if (getIntent() != null && getIntent().hasExtra(EXTRA_DRAWER_FRAGMENT)) { + loadDrawerFragment(getIntent().getIntExtra(EXTRA_DRAWER_FRAGMENT, + DrawerAdapter.ITEM_FAVOURITES)); + } else { + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + } + } + + // If we're given a Mumble URL to show, open up a server edit fragment. + if (getIntent() != null && + Intent.ACTION_VIEW.equals(getIntent().getAction())) { + String url = getIntent().getDataString(); + try { + Server server = MumbleURLParser.parseURL(url); + + // Open a dialog prompting the user to connect to the Mumble server. + DialogFragment fragment = ServerEditFragment.createServerEditDialog( + MumlaActivity.this, server, ServerEditFragment.Action.CONNECT_ACTION, true); + fragment.show(getSupportFragmentManager(), "url_edit"); + } catch (MalformedURLException e) { + Toast.makeText(this, getString(R.string.mumble_url_parse_failed), Toast.LENGTH_LONG).show(); + e.printStackTrace(); + } + } + + setVolumeControlStream(mSettings.isHandsetMode() ? + AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC); + + if (savedInstanceState == null) { + // Got no instance bundle: this is run only on real app startup -- not when Android + // recreates the activity on configuration change, like screen rotation. + if (mSettings.isFirstRun()) { + ensureDefaultCertificate(); + } else { + new StartupAction().execute(this); + } + } + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mDrawerToggle.syncState(); + } + + @Override + protected void onResume() { + super.onResume(); + Intent connectIntent = new Intent(this, MumlaService.class); + bindService(connectIntent, mConnection, 0); + } + + @Override + protected void onPause() { + super.onPause(); + if (mErrorDialog != null) + mErrorDialog.dismiss(); + if (mConnectingDialog != null) + mConnectingDialog.dismiss(); + + if (mService != null) { + for (HumlaServiceFragment fragment : mServiceFragments) { + fragment.setServiceBound(false); + } + mService.unregisterObserver(mObserver); + mService.setSuppressNotifications(false); + } + unbindService(mConnection); + } + + @Override + protected void onDestroy() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + preferences.unregisterOnSharedPreferenceChangeListener(this); + mDatabase.close(); + super.onDestroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem disconnectButton = menu.findItem(R.id.action_disconnect); + disconnectButton.setVisible(mService != null && mService.isConnected()); + + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.mumla, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NotNull MenuItem item) { + if (mDrawerToggle.onOptionsItemSelected(item)) + return true; + if (item.getItemId() == R.id.action_disconnect) { + getService().disconnect(); + return true; + } + return false; + } + + @Override + public void onConfigurationChanged(@NotNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mDrawerToggle.onConfigurationChanged(newConfig); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Media-style keys are handled by MumlaService's MediaSession so they continue to work + // when the Activity loses focus or the screen turns off. Keeping them out of this path + // also prevents toggle-PTT from receiving the same key twice while the screen is on. + if (mService != null && RadioPttKeyManager.isConfiguredPttKey(keyCode, mSettings) + && !isMediaPttKey(keyCode)) { + mService.onTalkKeyDown(); + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (mService != null && RadioPttKeyManager.isConfiguredPttKey(keyCode, mSettings) + && !isMediaPttKey(keyCode)) { + mService.onTalkKeyUp(); + return true; + } + return super.onKeyUp(keyCode, event); + } + + private static boolean isMediaPttKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_MEDIA_PLAY + || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_STOP + || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS + || keyCode == KeyEvent.KEYCODE_HEADSETHOOK; + } + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + mDrawerLayout.closeDrawers(); + loadDrawerFragment((int) id); + } + + private void ensureDefaultCertificate() { + if (mSettings.isUsingCertificate()) { + mSettings.setFirstRun(false); + new StartupAction().execute(this); + return; + } + + // A client certificate is local identity material, not user configuration. Generate it + // silently on first startup so radio devices can proceed without an interaction dialog. + MumlaCertificateGenerateTask generateTask = + new MumlaCertificateGenerateTask(MumlaActivity.this, false) { + @Override + protected void onPostExecute(se.lublin.mumla.db.DatabaseCertificate result) { + super.onPostExecute(result); + if (result != null) { + mSettings.setDefaultCertificateId(result.getId()); + mSettings.setFirstRun(false); + } else { + // Leave firstRun set so the next startup retries automatically. + Toast.makeText(MumlaActivity.this, R.string.generateCertFailure, + Toast.LENGTH_SHORT).show(); + } + new StartupAction().execute(MumlaActivity.this); + } + }; + generateTask.execute(); + } + + /** + * Loads a fragment from the drawer. + */ + private void loadDrawerFragment(int fragmentId) { + Class fragmentClass = null; + Bundle args = new Bundle(); + switch (fragmentId) { + case DrawerAdapter.ITEM_SERVER: + fragmentClass = ChannelFragment.class; + break; + case DrawerAdapter.ITEM_INFO: + fragmentClass = ServerInfoFragment.class; + break; + case DrawerAdapter.ITEM_ACCESS_TOKENS: + fragmentClass = AccessTokenFragment.class; + Server connectedServer = getService().getTargetServer(); + args.putLong("server", connectedServer.getId()); + args.putStringArrayList("access_tokens", (ArrayList) mDatabase.getAccessTokens(connectedServer.getId())); + break; + case DrawerAdapter.ITEM_PINNED_CHANNELS: + fragmentClass = ChannelFragment.class; + args.putBoolean("pinned", true); + break; + case DrawerAdapter.ITEM_FAVOURITES: + fragmentClass = FavouriteServerListFragment.class; + break; + case DrawerAdapter.ITEM_PUBLIC: + fragmentClass = PublicServerListFragment.class; + break; + case DrawerAdapter.ITEM_SETTINGS: + Intent prefIntent = new Intent(this, SettingsActivity.class); + startActivity(prefIntent); + return; + default: + return; + } + Fragment fragment = Fragment.instantiate(this, fragmentClass.getName(), args); + getSupportFragmentManager().beginTransaction() + .replace(R.id.content_frame, fragment, fragmentClass.getName()) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .commit(); + requireNonNull(getSupportActionBar()).setTitle(mDrawerAdapter.getItemWithId(fragmentId).title); + } + + public void connectToServer(final Server server) { + mServerPendingPerm = server; + connectToServerWithPerm(); + } + + public void connectToServerWithPerm() { + if (ContextCompat.checkSelfPermission(MumlaActivity.this, + Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(MumlaActivity.this, + new String[]{Manifest.permission.RECORD_AUDIO}, + PERMISSIONS_REQUEST_RECORD_AUDIO); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !mPermPostNotificationsAsked) { + if (ContextCompat.checkSelfPermission(MumlaActivity.this, + Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(MumlaActivity.this, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + PERMISSIONS_REQUEST_POST_NOTIFICATIONS); + return; + } + } + + if (mServerPendingPerm == null) { + Log.w(TAG, "No pending server after getting permissions"); + return; + } + + Server server = mServerPendingPerm; + mServerPendingPerm = null; + + // Check if we're already connected to a server; if so, inform user. + if (mService != null && mService.isConnected()) { + new MaterialAlertDialogBuilder(this) + .setMessage(R.string.reconnect_dialog_message) + .setPositiveButton(R.string.connect, (dialog, which) -> { + // Register an observer to reconnect to the new server once disconnected. + mService.registerObserver(new HumlaObserver() { + @Override + public void onDisconnected(HumlaException e) { + connectToServer(server); + mService.unregisterObserver(this); + } + }); + mService.disconnect(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + return; + } + + if (mSettings.isTorEnabled()) { + if (!OrbotHelper.isOrbotInstalled(this)) { + mSettings.disableTor(); + new MaterialAlertDialogBuilder(MumlaActivity.this) + .setMessage(R.string.orbot_not_installed) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; + } else { + if (!isPortOpen(HumlaConnection.TOR_HOST, HumlaConnection.TOR_PORT, 2000)) { + new MaterialAlertDialogBuilder(MumlaActivity.this) + .setMessage(getString(R.string.orbot_tor_failed, HumlaConnection.TOR_PORT)) + .setPositiveButton(android.R.string.ok, null) + .show(); + return; + } + } + } + + ServerConnectTask connectTask = new ServerConnectTask(this, mDatabase); + connectTask.execute(server); + } + + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (grantResults.length == 0) { + return; + } + + switch (requestCode) { + case PERMISSIONS_REQUEST_RECORD_AUDIO: + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + connectToServerWithPerm(); + } else { + Toast.makeText(MumlaActivity.this, getString(R.string.grant_perm_microphone), + Toast.LENGTH_LONG).show(); + } + break; + case PERMISSIONS_REQUEST_POST_NOTIFICATIONS: + mPermPostNotificationsAsked = true; + if (grantResults[0] == PackageManager.PERMISSION_DENIED) { + // This is inspired by https://stackoverflow.com/a/34612503 + if (ActivityCompat.shouldShowRequestPermissionRationale(MumlaActivity.this, + Manifest.permission.POST_NOTIFICATIONS)) { + Toast.makeText(MumlaActivity.this, + getString(R.string.grant_perm_notifications), Toast.LENGTH_LONG).show(); + } + } + connectToServerWithPerm(); + break; + } + } + + private boolean isPortOpen(final String host, final int port, final int timeout) { + final AtomicBoolean open = new AtomicBoolean(false); + try { + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + Socket socket = new Socket(); + socket.connect(new InetSocketAddress(host, port), timeout); + socket.close(); + open.set(true); + } catch (Exception e) { + Log.d(TAG, "isPortOpen() run()" + e); + } + } + }); + thread.start(); + thread.join(); + return open.get(); + } catch (Exception e) { + Log.d(TAG, "isPortOpen() " + e); + } + return false; + } + + public void connectToPublicServer(final PublicServer server) { + final Settings settings = Settings.getInstance(this); + final EditText usernameField = new EditText(this); + usernameField.setHint(settings.getDefaultUsername()); + FrameLayout layout = new FrameLayout(this); + layout.addView(usernameField); + int horizontalPadding = (int) getResources().getDimension(R.dimen.padding_medium); + layout.setPadding(horizontalPadding, 0, horizontalPadding, 0); + new MaterialAlertDialogBuilder(this) + .setView(layout) + .setTitle(R.string.connectToServer) + .setPositiveButton(R.string.connect, (dialog, which) -> { + if (usernameField.getText().toString().isEmpty()) { + server.setUsername(settings.getDefaultUsername()); + } else { + server.setUsername(usernameField.getText().toString()); + } + connectToServer(server); + }) + .show(); + } + + private void setStayAwake(boolean stayAwake) { + if (stayAwake) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + /** + * Updates the activity to represent the connection state of the given service. + * Will show reconnecting dialog if reconnecting, dismiss otherwise, etc. + * Basically, this service will do catch-up if the activity wasn't bound to receive + * connection state updates. + * + * @param service A bound IHumlaService. + */ + private void updateConnectionState(IHumlaService service) { + if (mConnectingDialog != null) { + mConnectingDialog.dismiss(); + } + if (mErrorDialog != null) + mErrorDialog.dismiss(); + + switch (mService.getConnectionState()) { + case CONNECTING: + Server server = service.getTargetServer(); + // SRV lookup is done later, so we no longer show the port in the connection + // progress dialog (and only the configured hostname) + mConnectingDialog = new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.connecting_to_server, server.getHost()) + (mSettings.isTorEnabled() ? " (Tor)" : "")) + .setView(R.layout.dialog_progress) + .setCancelable(true) + .setOnCancelListener(dialog -> { + mService.disconnect(); + Toast.makeText(MumlaActivity.this, R.string.cancelled, + Toast.LENGTH_SHORT).show(); + }) + .create(); + mConnectingDialog.show(); + break; + case CONNECTION_LOST: + // Only bother the user if the error hasn't already been shown. + if (getService() != null && !getService().isErrorShown()) { + // TODO? bail out if service gone -- it is happening! + if (getService() == null) { + break; + } + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(MumlaActivity.this); + builder.setTitle(getString(R.string.connectionRefused) + (mSettings.isTorEnabled() ? " (Tor)" : "")); + HumlaException error = getService().getConnectionError(); + if (error != null && mService.isReconnecting()) { + builder.setMessage(error.getMessage() + "\n\n" + + getString(R.string.attempting_reconnect, + error.getCause() != null ? error.getCause().getMessage() : "unknown")); + builder.setPositiveButton(R.string.cancel_reconnect, (dialog, which) -> { + if (getService() != null) { + getService().cancelReconnect(); + getService().markErrorShown(); + } + }); + } else if (error != null && + error.getReason() == HumlaException.HumlaDisconnectReason.REJECT && + (error.getReject().getType() == Mumble.Reject.RejectType.WrongUserPW || + error.getReject().getType() == Mumble.Reject.RejectType.WrongServerPW)) { + final EditText passwordField = new EditText(this); + passwordField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + passwordField.setHint(R.string.password); + builder.setTitle(R.string.invalid_password); + builder.setMessage(error.getMessage()); + builder.setView(passwordField); + builder.setPositiveButton(R.string.reconnect, (dialog, which) -> { + Server server1 = getService().getTargetServer(); + if (server1 == null) { + return; + } + String password = passwordField.getText().toString(); + server1.setPassword(password); + if (server1.isSaved()) { + mDatabase.updateServer(server1); + } + connectToServer(server1); + }); + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + if (getService() != null) { + getService().markErrorShown(); + } + }); + } else { + String msg = error != null ? error.getMessage() : getString(R.string.unknown); + builder.setMessage(msg); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (getService() != null) { + getService().markErrorShown(); + } + }); + } + builder.setCancelable(false); + mErrorDialog = builder.show(); + } + break; + } + } + + /* + * HERE BE IMPLEMENTATIONS + */ + + @Override + public IMumlaService getService() { + return mService; + } + + @Override + public MumlaDatabase getDatabase() { + return mDatabase; + } + + @Override + public void addServiceFragment(HumlaServiceFragment fragment) { + mServiceFragments.add(fragment); + } + + @Override + public void removeServiceFragment(HumlaServiceFragment fragment) { + mServiceFragments.remove(fragment); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @Nullable String key) { + if (key == null) { + return; + } + switch (key) { + case Settings.PREF_STAY_AWAKE: + setStayAwake(mSettings.shouldStayAwake()); + break; + case Settings.PREF_HANDSET_MODE: + setVolumeControlStream(mSettings.isHandsetMode() ? AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC); + break; + } + } + + @Override + public boolean isConnected() { + return mService != null && mService.isConnected(); + } + + @Override + public String getConnectedServerName() { + if (mService != null && mService.isConnected()) { + Server server = mService.getTargetServer(); + return server.getName().isEmpty() ? server.getHost() : server.getName(); + } + if (BuildConfig.DEBUG) + throw new RuntimeException("getConnectedServerName should only be called if connected!"); + return ""; + } + + @Override + public void onServerEdited(ServerEditFragment.Action action, Server server) { + switch (action) { + case ADD_ACTION: + mDatabase.addServer(server); + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + break; + case EDIT_ACTION: + mDatabase.updateServer(server); + loadDrawerFragment(DrawerAdapter.ITEM_FAVOURITES); + break; + case CONNECT_ACTION: + connectToServer(server); + break; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/app/MumlaApplication.java b/app/src/main/java/se/lublin/mumla/app/MumlaApplication.java new file mode 100644 index 00000000..e01c2578 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/app/MumlaApplication.java @@ -0,0 +1,70 @@ +package se.lublin.mumla.app; + +import static androidx.appcompat.app.AppCompatDelegate.setApplicationLocales; +import static androidx.core.os.LocaleListCompat.forLanguageTags; +import static androidx.core.os.LocaleListCompat.getEmptyLocaleList; +import static se.lublin.mumla.Settings.PREF_LANGUAGE; +import static se.lublin.mumla.Settings.PREF_THEME; + +import android.app.Application; +import android.content.SharedPreferences; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.preference.PreferenceManager; + +import se.lublin.mumla.radio.DeviceIdentityManager; +import se.lublin.mumla.radio.RadioConfigUpdater; +import se.lublin.mumla.radio.RadioLauncherShortcutInstaller; +import se.lublin.mumla.radio.RadioPttKeyManager; + +public class MumlaApplication extends Application implements SharedPreferences.OnSharedPreferenceChangeListener { + @Override + public void onCreate() { + super.onCreate(); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + // Create the radio identity before either the Activity or service needs it. This keeps + // the identity stable across normal app launches and makes it available to config code. + new DeviceIdentityManager(preferences).getOrCreateDeviceId(); + RadioPttKeyManager.applyDefaults(preferences); + RadioLauncherShortcutInstaller.ensureInstalled(this, preferences, false); + RadioConfigUpdater.start(this); + applyTheme(preferences); + preferences.registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences preferences, @Nullable String key) { + if (key == null) { + return; + } + switch (key) { + case PREF_LANGUAGE: + String language = preferences.getString(PREF_LANGUAGE, "system"); + setApplicationLocales(language.equals("system") ? getEmptyLocaleList() : forLanguageTags(language)); + break; + case PREF_THEME: + applyTheme(preferences); + break; + } + } + + private static void applyTheme(SharedPreferences preferences) { + // Minimum starts in dark mode unless the user has explicitly selected another theme. + switch (preferences.getString(PREF_THEME, "forceDark")) { + case "forceLight": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case "forceDark": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case "system": + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + break; + default: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + preferences.edit().putString(PREF_THEME, "forceDark").apply(); + break; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/app/MumlaBootReceiver.java b/app/src/main/java/se/lublin/mumla/app/MumlaBootReceiver.java new file mode 100644 index 00000000..1f47f202 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/app/MumlaBootReceiver.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.app; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import se.lublin.mumla.Settings; +import se.lublin.mumla.radio.RadioDeviceProfile; +import se.lublin.mumla.radio.RadioProcessWatchdog; +import se.lublin.mumla.radio.RadioShellActivity; + +/** Opens the radio client after boot on hardware that permits boot-time activity launches. */ +public class MumlaBootReceiver extends BroadcastReceiver { + private static final String TAG = MumlaBootReceiver.class.getName(); + + @Override + public void onReceive(Context context, Intent intent) { + if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + return; + } + + if (!Settings.getInstance(context).isAutoStartEnabled()) { + return; + } + + String profile = RadioDeviceProfile.detectCurrent(); + boolean radioProfile = RadioDeviceProfile.T99.equals(profile) + || RadioDeviceProfile.T56.equals(profile); + if (radioProfile) { + RadioProcessWatchdog.arm(context); + } + Class launchClass = radioProfile ? RadioShellActivity.class : MumlaActivity.class; + Intent launchIntent = new Intent(context, launchClass); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_SINGLE_TOP); + try { + context.startActivity(launchIntent); + } catch (RuntimeException exception) { + // Some newer Android/OEM builds block background activity launches. Keep this quiet + // and let the future foreground-service notification path handle those devices. + Log.w(TAG, "Could not launch Mumla after boot", exception); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/app/ServerConnectTask.java b/app/src/main/java/se/lublin/mumla/app/ServerConnectTask.java new file mode 100644 index 00000000..e4da4d09 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/app/ServerConnectTask.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.app; + +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaRecorder; +import android.os.AsyncTask; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.humla.HumlaService; +import se.lublin.humla.model.Server; +import se.lublin.mumla.BuildConfig; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.service.MumlaService; +import se.lublin.mumla.util.MumlaTrustStore; + +/** + * Constructs an intent for connection to a MumlaService and executes it. + * Created by andrew on 20/08/14. + */ +public class ServerConnectTask extends AsyncTask { + private static final long MANAGED_RADIO_CONNECTION_INTERVAL_MS = 15000L; + + private Context mContext; + private MumlaDatabase mDatabase; + private Settings mSettings; + private final List mAccessTokens; + private final Boolean mAutoReconnect; + + public ServerConnectTask(Context context, MumlaDatabase database) { + this(context, database, null, null); + } + + /** + * Creates a connection task with optional radio-config overrides. Tokens are copied and never + * persisted to the Mumla database by this task. + */ + public ServerConnectTask(Context context, MumlaDatabase database, List accessTokens, + Boolean autoReconnect) { + mContext = context; + mDatabase = database; + mSettings = Settings.getInstance(context); + mAccessTokens = accessTokens == null ? null : new ArrayList<>(accessTokens); + mAutoReconnect = autoReconnect; + } + + @Override + protected Intent doInBackground(Server... params) { + Server server = params[0]; + + /* Convert input method defined in settings to an integer format used by Humla. */ + int inputMethod = mSettings.getHumlaInputMethod(); + + int audioSource = mSettings.isHandsetMode() ? + MediaRecorder.AudioSource.DEFAULT : MediaRecorder.AudioSource.MIC; + int audioStream = mSettings.isHandsetMode() ? + AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC; + + Intent connectIntent = new Intent(mContext, MumlaService.class); + boolean managedRadioConnection = mAutoReconnect != null; + connectIntent.putExtra(HumlaService.EXTRAS_SERVER, server); + connectIntent.putExtra(HumlaService.EXTRAS_CLIENT_NAME, mContext.getString(R.string.app_name)+" "+ BuildConfig.VERSION_NAME); + connectIntent.putExtra(HumlaService.EXTRAS_TRANSMIT_MODE, inputMethod); + connectIntent.putExtra(HumlaService.EXTRAS_DETECTION_THRESHOLD, mSettings.getDetectionThreshold()); + connectIntent.putExtra(HumlaService.EXTRAS_AMPLITUDE_BOOST, mSettings.getAmplitudeBoostMultiplier()); + connectIntent.putExtra(HumlaService.EXTRAS_AUTO_RECONNECT, + mAutoReconnect == null ? mSettings.isAutoReconnectEnabled() : mAutoReconnect); + connectIntent.putExtra(HumlaService.EXTRAS_AUTO_RECONNECT_DELAY, + mAutoReconnect == null ? MumlaService.RECONNECT_DELAY + : (int) MANAGED_RADIO_CONNECTION_INTERVAL_MS); + connectIntent.putExtra(HumlaService.EXTRAS_RECONNECT_ON_ALL_ERRORS, + false); + connectIntent.putExtra(HumlaService.EXTRAS_REDELIVER_CONNECT_INTENT, + managedRadioConnection && Boolean.TRUE.equals(mAutoReconnect)); + connectIntent.putExtra(HumlaService.EXTRAS_MIN_CONNECTION_ATTEMPT_INTERVAL, + managedRadioConnection ? MANAGED_RADIO_CONNECTION_INTERVAL_MS : 0L); + connectIntent.putExtra(HumlaService.EXTRAS_USE_OPUS, !mSettings.isOpusDisabled()); + connectIntent.putExtra(HumlaService.EXTRAS_INPUT_RATE, mSettings.getInputSampleRate()); + connectIntent.putExtra(HumlaService.EXTRAS_INPUT_QUALITY, mSettings.getInputQuality()); + connectIntent.putExtra(HumlaService.EXTRAS_FORCE_TCP, mSettings.isTcpForced()); + connectIntent.putExtra(HumlaService.EXTRAS_USE_TOR, mSettings.isTorEnabled()); + ArrayList accessTokens = mAccessTokens == null + ? new ArrayList<>(mDatabase.getAccessTokens(server.getId())) + : new ArrayList<>(mAccessTokens); + connectIntent.putStringArrayListExtra(HumlaService.EXTRAS_ACCESS_TOKENS, accessTokens); + connectIntent.putExtra(HumlaService.EXTRAS_AUDIO_SOURCE, audioSource); + connectIntent.putExtra(HumlaService.EXTRAS_AUDIO_STREAM, audioStream); + connectIntent.putExtra(HumlaService.EXTRAS_FRAMES_PER_PACKET, mSettings.getFramesPerPacket()); + connectIntent.putExtra(HumlaService.EXTRAS_TRUST_STORE, MumlaTrustStore.getTrustStorePath(mContext)); + connectIntent.putExtra(HumlaService.EXTRAS_TRUST_STORE_PASSWORD, MumlaTrustStore.getTrustStorePassword()); + connectIntent.putExtra(HumlaService.EXTRAS_TRUST_STORE_FORMAT, MumlaTrustStore.getTrustStoreFormat()); + connectIntent.putExtra(HumlaService.EXTRAS_HALF_DUPLEX, mSettings.isHalfDuplex()); + connectIntent.putExtra(HumlaService.EXTRAS_ENABLE_PREPROCESSOR, mSettings.isPreprocessorEnabled()); + connectIntent.putExtra(HumlaService.EXTRAS_ECHO_CANCELLATION_METHOD, mSettings.getEchoCancellationMethod()); + if (server.isSaved()) { + ArrayList muteHistory = (ArrayList) mDatabase.getLocalMutedUsers(server.getId()); + ArrayList ignoreHistory = (ArrayList) mDatabase.getLocalIgnoredUsers(server.getId()); + connectIntent.putExtra(HumlaService.EXTRAS_LOCAL_MUTE_HISTORY, muteHistory); + connectIntent.putExtra(HumlaService.EXTRAS_LOCAL_IGNORE_HISTORY, ignoreHistory); + } + + if (mSettings.isUsingCertificate()) { + long certificateId = mSettings.getDefaultCertificate(); + byte[] certificate = mDatabase.getCertificateData(certificateId); + if (certificate != null) + connectIntent.putExtra(HumlaService.EXTRAS_CERTIFICATE, certificate); + // TODO(acomminos): handle the case where a certificate's data is unavailable. + } + + connectIntent.setAction(HumlaService.ACTION_CONNECT); + return connectIntent; + } + + @Override + protected void onPostExecute(Intent intent) { + super.onPostExecute(intent); + mContext.startService(intent); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/AccessTokenFragment.java b/app/src/main/java/se/lublin/mumla/channel/AccessTokenFragment.java new file mode 100644 index 00000000..3dc372c1 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/AccessTokenFragment.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.TextView.OnEditorActionListener; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.mumla.R; +import se.lublin.mumla.db.DatabaseProvider; +import se.lublin.mumla.util.HumlaServiceFragment; + +public class AccessTokenFragment extends HumlaServiceFragment { + private static final String TAG = AccessTokenFragment.class.getName(); + + public interface AccessTokenListener { + public void onAccessTokenAdded(long serverId, String token); + + public void onAccessTokenRemoved(long serverId, String token); + } + + private List mTokens; + + private ListView mTokenList; + private TokenAdapter mTokenAdapter; + private EditText mTokenField; + + private DatabaseProvider mProvider; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + mTokens = new ArrayList(getAccessTokens()); + mTokenAdapter = new TokenAdapter(activity, mTokens); + + try { + mProvider = (DatabaseProvider) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement DatabaseProvider"); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_tokens, container, false); + + mTokenList = (ListView) view.findViewById(R.id.tokenList); + mTokenList.setAdapter(mTokenAdapter); + + mTokenField = (EditText) view.findViewById(R.id.tokenField); + mTokenField.setOnEditorActionListener(new OnEditorActionListener() { + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if(actionId == EditorInfo.IME_ACTION_SEND) { + addToken(); + return true; + } + return false; + } + }); + + ImageButton addButton = (ImageButton) view.findViewById(R.id.tokenAddButton); + addButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + addToken(); + } + }); + + return view; + } + + private void addToken() { + String tokenText = mTokenField.getText().toString().trim(); + + if(tokenText.equals("")) + return; + + mTokenField.setText(""); + + Log.i(TAG, "Adding a token"); + + mTokens.add(tokenText); + mTokenAdapter.notifyDataSetChanged(); + + mTokenList.smoothScrollToPosition(mTokens.size() - 1); + mProvider.getDatabase().addAccessToken(getServerId(), tokenText); + if (getService() != null && getService().isConnected()) { + getService().HumlaSession().sendAccessTokens(mTokens); + } + } + + private long getServerId() { + return getArguments().getLong("server"); + } + + private List getAccessTokens() { + return getArguments().getStringArrayList("access_tokens"); + } + + private class TokenAdapter extends ArrayAdapter { + + public TokenAdapter(Context context, + List objects) { + super(context, android.R.layout.simple_list_item_1, objects); + } + + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + View view = convertView; + if(convertView == null) { + view = getActivity().getLayoutInflater().inflate(R.layout.token_row, null, false); + } + + final String token = getItem(position); + + TextView title = (TextView) view.findViewById(R.id.tokenItemTitle); + title.setText(token); + + ImageButton deleteButton = (ImageButton) view.findViewById(R.id.tokenItemDelete); + deleteButton.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + mTokens.remove(position); + notifyDataSetChanged(); + mProvider.getDatabase().removeAccessToken(getServerId(), token); + if (getService() != null && getService().isConnected()) { + getService().HumlaSession().sendAccessTokens(mTokens); + } + } + }); + + return view; + } + + } + +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelAdapter.java b/app/src/main/java/se/lublin/mumla/channel/ChannelAdapter.java new file mode 100644 index 00000000..9fed2c34 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelAdapter.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.TalkState; +import se.lublin.humla.model.User; +import se.lublin.mumla.R; + +/** + * Simple adapter to display the users in a single channel. + * Created by andrew on 24/11/13. + */ +public class ChannelAdapter extends BaseAdapter { + + private Context mContext; + private IChannel mChannel; + + public ChannelAdapter(Context context, IChannel channel) { + mContext = context; + mChannel = channel; + } + + @Override + public int getCount() { + return mChannel.getUsers().size(); + } + + @Override + public Object getItem(int position) { + return mChannel.getUsers().get(position); + } + + @Override + public long getItemId(int position) { + IUser user = mChannel.getUsers().get(position); + if (user != null) + return user.getUserId(); + return -1; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = convertView; + if(v == null) { + LayoutInflater layoutInflater = LayoutInflater.from(mContext); + v = layoutInflater.inflate(R.layout.overlay_user_row, parent, false); + } + User user = (User) getItem(position); + TextView titleView = (TextView) v.findViewById(R.id.user_row_name); + titleView.setText(user.getName()); + + ImageView state = (ImageView) v.findViewById(R.id.user_row_state); + if (user.isSelfDeafened()) + state.setImageResource(R.drawable.outline_circle_deafened); + else if (user.isSelfMuted()) + state.setImageResource(R.drawable.outline_circle_muted); + else if (user.isDeafened()) + state.setImageResource(R.drawable.outline_circle_server_deafened); + else if (user.isMuted()) + state.setImageResource(R.drawable.outline_circle_server_muted); + else if (user.isSuppressed()) + state.setImageResource(R.drawable.outline_circle_suppressed); + else + if (user.getTalkState() == TalkState.TALKING) + state.setImageResource(R.drawable.outline_circle_talking_on); + else + state.setImageResource(R.drawable.outline_circle_talking_off); + + return v; + } + + public void setChannel(IChannel channel) { + mChannel = channel; + notifyDataSetChanged(); + } + + public IChannel getChannel() { + return mChannel; + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelChatFragment.java b/app/src/main/java/se/lublin/mumla/channel/ChannelChatFragment.java new file mode 100644 index 00000000..0297c02f --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelChatFragment.java @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import static android.Manifest.permission.READ_EXTERNAL_STORAGE; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static android.os.Build.VERSION.SDK_INT; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.inputmethod.EditorInfo.IME_NULL; + +import static androidx.core.content.ContextCompat.checkSelfPermission; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.text.Html; +import android.text.TextWatcher; +import android.text.method.LinkMovementMethod; +import android.util.Base64; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.GetContent; +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; +import androidx.exifinterface.media.ExifInterface; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IMessage; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.User; +import se.lublin.humla.util.HumlaDisconnectedException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.humla.util.IHumlaObserver; +import se.lublin.mumla.R; +import se.lublin.mumla.service.IChatMessage; +import se.lublin.mumla.util.BitmapUtils; +import se.lublin.mumla.util.HumlaServiceFragment; +import se.lublin.mumla.util.MumbleImageGetter; + +public class ChannelChatFragment extends HumlaServiceFragment implements ChatTargetProvider.OnChatTargetSelectedListener { + private static final String TAG = ChannelChatFragment.class.getName(); + + private static final Pattern LINK_PATTERN = Pattern.compile("(https?://\\S+)"); + + private IHumlaObserver mServiceObserver = new HumlaObserver() { + + @Override + public void onMessageLogged(IMessage message) { + addChatMessage(new IChatMessage.TextMessage(message), true); + } + + @Override + public void onLogInfo(String message) { + addChatMessage(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.INFO, message), true); + } + + @Override + public void onLogWarning(String message) { + addChatMessage(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.WARNING, message), true); + } + + @Override + public void onLogError(String message) { + addChatMessage(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.ERROR, message), true); + } + + @Override + public void onUserJoinedChannel(IUser user, IChannel newChannel, IChannel oldChannel) { + IHumlaService service = getService(); + if (service != null && service.isConnected()) { + IHumlaSession session = service.HumlaSession(); + if (user != null && session.getSessionUser() != null && + user.equals(session.getSessionUser()) && + mTargetProvider.getChatTarget() == null) { + // Update chat target when user changes channels without a target. + updateChatTargetText(null); + } + } + } + }; + + private ListView mChatList; + private ChannelChatAdapter mChatAdapter; + private EditText mChatTextEdit; + private ImageButton mSendButton; + private ChatTargetProvider mTargetProvider; + ActivityResultLauncher imagePicker = + registerForActivityResult(new GetContent(), this::onImagePicked); + private final ActivityResultLauncher readPermissionRequester = + registerForActivityResult(new RequestPermission(), this::onReadPermissionGranted); + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mTargetProvider = (ChatTargetProvider) getParentFragment(); + } catch (ClassCastException e) { + throw new ClassCastException(getParentFragment().toString() + " must implement ChatTargetProvider"); + } + } + + @Override + public void onResume() { + super.onResume(); + mTargetProvider.registerChatTargetListener(this); + } + + @Override + public void onPause() { + super.onPause(); + mTargetProvider.unregisterChatTargetListener(this); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_chat, container, false); + mChatList = view.findViewById(R.id.chat_list); + mChatTextEdit = view.findViewById(R.id.chatTextEdit); + + ImageButton ImageSendButton = view.findViewById(R.id.chatImageSend); + ImageSendButton.setOnClickListener(buttonView -> { + // Android <= 12 (SDK 32) needs extra permission + if (SDK_INT <= Build.VERSION_CODES.S_V2) { + if (checkSelfPermission(requireContext(), READ_EXTERNAL_STORAGE) != PERMISSION_GRANTED) { + readPermissionRequester.launch(READ_EXTERNAL_STORAGE); + } else { + // Reaching here once app has gotten hold of the permission + imagePicker.launch("image/*"); + } + } else { + imagePicker.launch("image/*"); + } + }); + + mSendButton = view.findViewById(R.id.chatTextSend); + mSendButton.setOnClickListener(this::sendMessageFromEditor); + + mChatTextEdit.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == IME_NULL && event != null && event.getKeyCode() == KEYCODE_ENTER) { + sendMessageFromEditor(v); + return true; + } + return false; + }); + + mChatTextEdit.addTextChangedListener(new TextWatcher() { + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + mSendButton.setEnabled(mChatTextEdit.getText().length() > 0); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void afterTextChanged(Editable s) { + + } + }); + + updateChatTargetText(mTargetProvider.getChatTarget()); + return view; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.fragment_chat, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.menu_clear_chat) { + clear(); + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * Adds the passed text to the fragment chat body. + * + * @param message The message to add. + * @param scroll Whether to scroll to the bottom after adding the message. + */ + public void addChatMessage(IChatMessage message, boolean scroll) { + if (mChatAdapter == null) return; + + mChatAdapter.add(message); + + if (scroll) { + mChatList.post(new Runnable() { + + @Override + public void run() { + mChatList.smoothScrollToPosition(mChatAdapter.getCount() - 1); + } + }); + } + } + + private void onReadPermissionGranted(Boolean isGranted) { + if (isGranted) { + imagePicker.launch("image/*"); + } else { + Toast.makeText(requireContext(), "Permission denied to read storage", Toast.LENGTH_LONG).show(); + } + } + + private void onImagePicked(Uri uri) { + if (uri == null) { + return; + } + if (getService() == null || !getService().isConnected()) { + return; + } + + // We don't fail on errors when getting orientation + boolean flipped = false; + int rotationDeg = 0; + try (InputStream imageStream = requireContext().getContentResolver().openInputStream(uri)) { + if (imageStream == null) { + Log.w(TAG, "openInputStream(uri) failed for orientation"); + } else { + ExifInterface exif = new ExifInterface(imageStream); + flipped = exif.isFlipped(); + rotationDeg = exif.getRotationDegrees(); + } + } catch (IOException e) { + Log.w(TAG, "exception when getting orientation: " + e); + } + Log.d(TAG, "flipped:" + flipped + " rotationDeg:" + rotationDeg); + + InputStream imageStream; + try { + imageStream = requireContext().getContentResolver().openInputStream(uri); + if (imageStream == null) { + Log.w(TAG, "openInputStream(uri) failed"); + return; + } + } catch (IOException e) { + Log.w(TAG, "exception when opening stream: " + e); + return; + } + + Bitmap bitmap = BitmapFactory.decodeStream(imageStream); + if (bitmap == null) { + Log.w(TAG, "decode to bitmap failed"); + return; + } + + if (flipped || rotationDeg > 0) { + Matrix matrix = new Matrix(); + if (flipped) { + // first flip horizontally, following {@link ExifInterface#getRotationDegrees()} + matrix.postScale(-1, 1, bitmap.getWidth() / 2f, bitmap.getHeight() / 2f); + } + if (rotationDeg > 0) { + matrix.postRotate(rotationDeg); + } + bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), + matrix, false); + } + + Bitmap resized = BitmapUtils.resizeKeepingAspect(bitmap, 600, 400); + + ImageView preview = new ImageView(requireContext()); + preview.setImageBitmap(resized); + preview.setAdjustViewBounds(true); + preview.setScaleType(ImageView.ScaleType.FIT_CENTER); + preview.setMaxHeight(Resources.getSystem().getDisplayMetrics().heightPixels / 3); + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.image_confirm_send) + .setPositiveButton(android.R.string.ok, (dlg, which) -> onImageConfirmed(resized)) + .setNegativeButton(android.R.string.cancel, null) + .setView(preview) + .create() + .show(); + } + + private void onImageConfirmed(Bitmap resized) { + int maxSize = getService().HumlaSession().getServerSettings().getImageMessageLength(); + + // Lower the quality, compressing image harder until it fits + int quality = 97; + byte[] compressed; + do { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (!resized.compress(Bitmap.CompressFormat.JPEG, quality, stream)) { + Log.w(TAG, "compress failed, quality==" + quality); + } else { + compressed = stream.toByteArray(); + // Account for the base64 overhead + if (4 * (compressed.length / 3) + 4 < maxSize || maxSize == 0) { + break; + } else { + Log.d(TAG, "compress(quality==" + quality + ") >= " + maxSize + " bytes"); + } + } + compressed = null; + quality -= 10; + } while (quality > 0); + + if (compressed == null) { + Log.w(TAG, "all compress attempts failed"); + return; + } + + String imageStr = Base64.encodeToString(compressed, Base64.NO_WRAP); + String encoded = URLEncoder.encode(imageStr); + sendMessage(""); + } + + /** + * Sends the message currently in + * {@link se.lublin.mumla.channel.ChannelChatFragment#mChatTextEdit} + * to the remote server. Clears the message box if the message was sent successfully. + */ + private void sendMessageFromEditor(View v) { + if (mChatTextEdit.length() == 0) { + return; + } + String message = mChatTextEdit.getText().toString(); + try { + sendMessage(message); + mChatTextEdit.setText(""); + } catch (HumlaDisconnectedException e) { + Log.d(TAG, "exception from sendMessage: " + e); + } + } + + /** + * Sends a message to the remote server. + * + * @param message The message to send. + * @throws HumlaDisconnectedException If the service is disconnected. + */ + private void sendMessage(String message) throws HumlaDisconnectedException { + if (getService() == null) { + Log.d(TAG,"getService()==null in sendMessage"); + return; + } + IHumlaSession session = getService().HumlaSession(); + + String formattedMessage = markupOutgoingMessage(message); + ChatTargetProvider.ChatTarget target = mTargetProvider.getChatTarget(); + IMessage responseMessage = null; + if (target == null) + responseMessage = session.sendChannelTextMessage(session.getSessionChannel().getId(), formattedMessage, false); + else if (target.getUser() != null) + responseMessage = session.sendUserTextMessage(target.getUser().getSession(), formattedMessage); + else if (target.getChannel() != null) + responseMessage = session.sendChannelTextMessage(target.getChannel().getId(), formattedMessage, false); + addChatMessage(new IChatMessage.TextMessage(responseMessage), true); + } + + /** + * Adds HTML markup to the message, replacing links and newlines. + * + * @param message The message to markup. + * @return HTML data. + */ + private String markupOutgoingMessage(String message) { + String formattedBody = message; + Matcher matcher = LINK_PATTERN.matcher(formattedBody); + formattedBody = matcher.replaceAll("$1") + .replaceAll("\n", "
"); + return formattedBody; + } + + public void clear() { + if (mChatAdapter != null) { + mChatAdapter.clear(); + } + if (getService() != null) { + getService().clearMessageLog(); + } + } + + /** + * Updates hint displaying chat target. + */ + public void updateChatTargetText(final ChatTargetProvider.ChatTarget target) { + if (getService() == null || !getService().isConnected()) return; + + IHumlaSession session = getService().HumlaSession(); + String hint = null; + if (target == null && session.getSessionChannel() != null) { + hint = getString(R.string.messageToChannel, session.getSessionChannel().getName()); + } else if (target != null && target.getUser() != null) { + hint = getString(R.string.messageToUser, target.getUser().getName()); + } else if (target != null && target.getChannel() != null) { + hint = getString(R.string.messageToChannel, target.getChannel().getName()); + } + mChatTextEdit.setHint(hint); + mChatTextEdit.requestLayout(); // Needed to update bounds after hint change. + } + + + @Override + public void onServiceBound(IHumlaService service) { + if (getService() == null) { + return; + } + + mChatAdapter = new ChannelChatAdapter(getActivity(), service, getService().getMessageLog()); + mChatList.setAdapter(mChatAdapter); + mChatList.post(new Runnable() { + @Override + public void run() { + mChatList.setSelection(mChatAdapter.getCount() - 1); + } + }); + } + + @Override + public IHumlaObserver getServiceObserver() { + return mServiceObserver; + } + + @Override + public void onChatTargetSelected(ChatTargetProvider.ChatTarget target) { + updateChatTargetText(target); + } + + private static class ChannelChatAdapter extends ArrayAdapter { + private final MumbleImageGetter mImageGetter; + private final IHumlaService mService; + private final DateFormat mDateFormat; + + public ChannelChatAdapter(Context context, IHumlaService service, List messages) { + super(context, 0, new ArrayList<>(messages)); + mService = service; + mImageGetter = new MumbleImageGetter(context); + mDateFormat = SimpleDateFormat.getTimeInstance(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View v = convertView; + if (v == null) { + v = LayoutInflater.from(getContext()).inflate(R.layout.list_chat_item, parent, false); + } + + final LinearLayout chatBox = (LinearLayout) v.findViewById(R.id.list_chat_item_box); + final TextView targetText = (TextView) v.findViewById(R.id.list_chat_item_target); + final TextView messageText = (TextView) v.findViewById(R.id.list_chat_item_text); + TextView timeText = (TextView) v.findViewById(R.id.list_chat_item_time); + + IChatMessage message = getItem(position); + message.accept(new IChatMessage.Visitor() { + @Override + public void visit(IChatMessage.TextMessage message) { + IMessage textMessage = message.getMessage(); + String targetMessage = getContext().getString(R.string.unknown); + boolean selfAuthored; + try { + selfAuthored = textMessage.getActor() == mService.HumlaSession().getSessionId(); + } catch (HumlaDisconnectedException e) { + selfAuthored = false; + } + + if (textMessage.getTargetChannels() != null && !textMessage.getTargetChannels().isEmpty()) { + IChannel currentChannel = (IChannel) textMessage.getTargetChannels().get(0); + if (currentChannel != null && currentChannel.getName() != null) { + targetMessage = getContext().getString(R.string.chat_message_to, textMessage.getActorName(), currentChannel.getName()); + } + } else if (textMessage.getTargetTrees() != null && !textMessage.getTargetTrees().isEmpty()) { + IChannel currentChannel = (IChannel) textMessage.getTargetTrees().get(0); + if (currentChannel != null && currentChannel.getName() != null) { + targetMessage = getContext().getString(R.string.chat_message_to, textMessage.getActorName(), currentChannel.getName()); + } + } else if (textMessage.getTargetUsers() != null && !textMessage.getTargetUsers().isEmpty()) { + User user = textMessage.getTargetUsers().get(0); + if (user != null && user.getName() != null) { + targetMessage = getContext().getString(R.string.chat_message_to, textMessage.getActorName(), user.getName()); + } + } else { + targetMessage = textMessage.getActorName(); + } + + int gravity = selfAuthored ? Gravity.RIGHT : Gravity.LEFT; + chatBox.setGravity(gravity); + messageText.setGravity(gravity); + targetText.setText(targetMessage); + targetText.setVisibility(View.VISIBLE); + } + + @Override + public void visit(IChatMessage.InfoMessage message) { + targetText.setVisibility(View.GONE); + chatBox.setGravity(Gravity.LEFT); + messageText.setGravity(Gravity.LEFT); + } + }); + timeText.setText(mDateFormat.format(new Date(message.getReceivedTime()))); + messageText.setText(Html.fromHtml(message.getBody(), mImageGetter, null)); + messageText.setMovementMethod(LinkMovementMethod.getInstance()); + + return v; + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int position) { + return false; // Makes links clickable. + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelEditFragment.java b/app/src/main/java/se/lublin/mumla/channel/ChannelEditFragment.java new file mode 100644 index 00000000..c4858045 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelEditFragment.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.app.Activity; +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.net.Permissions; +import se.lublin.mumla.R; +import se.lublin.mumla.util.HumlaServiceProvider; + +/** + * Created by andrew on 23/11/13. + */ +public class ChannelEditFragment extends DialogFragment { + + private HumlaServiceProvider mServiceProvider; + private TextView mNameField; + private TextView mDescriptionField; + private TextView mPositionField; + private CheckBox mTemporaryBox; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mServiceProvider = (HumlaServiceProvider) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement HumlaServiceProvider"); + } + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + LayoutInflater inflater = LayoutInflater.from(getActivity()); + View view = inflater.inflate(R.layout.fragment_channel_edit, null, false); + mNameField = view.findViewById(R.id.channel_edit_name); + mDescriptionField = view.findViewById(R.id.channel_edit_description); + mPositionField = view.findViewById(R.id.channel_edit_position); + mTemporaryBox = view.findViewById(R.id.channel_edit_temporary); + + // If we can only make temporary channels, remove the option. + if (mServiceProvider.getService() != null && mServiceProvider.getService().isConnected()) { + // TODO: we probably should just stop this dialog in its tracks if we're disconnected. + IHumlaSession session = mServiceProvider.getService().HumlaSession(); + IChannel parentChannel = session.getChannel(getParent()); + int combinedPermissions = session.getPermissions() | parentChannel.getPermissions(); + boolean canMakeChannel = (combinedPermissions & Permissions.MakeChannel) > 0; + boolean canMakeTempChannel = (combinedPermissions & Permissions.MakeTempChannel) > 0; + boolean onlyTemp = canMakeTempChannel && !canMakeChannel; + mTemporaryBox.setChecked(onlyTemp); + mTemporaryBox.setEnabled(!onlyTemp); + } + + return new MaterialAlertDialogBuilder(requireContext()) + .setTitle(isAdding() ? R.string.channel_add : R.string.channel_edit) + .setView(view) + .setPositiveButton(isAdding() ? R.string.add : R.string.save, (dialog, which) -> { + if (isAdding() && mServiceProvider.getService() != null && mServiceProvider.getService().isConnected()) { + mServiceProvider.getService().HumlaSession().createChannel(getParent(), + mNameField.getText().toString(), + mDescriptionField.getText().toString(), + Integer.parseInt(mPositionField.getText().toString()), // We can guarantee this to be an int. InputType is numberSigned. + mTemporaryBox.isChecked()); + } else { + // TODO + } + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + } + + /** + * @return true if the user is adding a new channel. + */ + public boolean isAdding() { + return getArguments().getBoolean("adding"); + } + + /** + * @return the parent channel that the new channel will be a child of. + */ + public int getParent() { + return getArguments().getInt("parent"); + } + /** + * @return the channel being updated. + */ + public int getChannel() { + return getArguments().getInt("channel"); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelFragment.java b/app/src/main/java/se/lublin/mumla/channel/ChannelFragment.java new file mode 100644 index 00000000..0df2e0fd --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelFragment.java @@ -0,0 +1,405 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.preference.PreferenceManager; +import androidx.viewpager.widget.PagerTabStrip; +import androidx.viewpager.widget.ViewPager; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.humla.HumlaService; +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.WhisperTarget; +import se.lublin.humla.util.HumlaDisconnectedException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.humla.util.IHumlaObserver; +import se.lublin.humla.util.VoiceTargetMode; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.util.HumlaServiceFragment; + +/** + * Class to encapsulate both a ChannelListFragment and ChannelChatFragment. + * Created by andrew on 02/08/13. + */ +public class ChannelFragment extends HumlaServiceFragment implements SharedPreferences.OnSharedPreferenceChangeListener, ChatTargetProvider { + private static final String TAG = ChannelFragment.class.getName(); + + private ViewPager mViewPager; + private PagerTabStrip mTabStrip; + private Button mTalkButton; + private View mTalkView; + + private View mTargetPanel; + private ImageView mTargetPanelCancel; + private TextView mTargetPanelText; + + private ChatTarget mChatTarget; + /** Chat target listeners, notified when the chat target is changed. */ + private List mChatTargetListeners = new ArrayList(); + + /** True iff the talk button has been hidden (e.g. when muted) */ + private boolean mTalkButtonHidden; + + private HumlaObserver mObserver = new HumlaObserver() { + @Override + public void onUserTalkStateUpdated(IUser user) { + if (getService() == null || !getService().isConnected()) { + return; + } + int selfSession; + try { + selfSession = getService().HumlaSession().getSessionId(); + } catch (HumlaDisconnectedException|IllegalStateException e) { + Log.d(TAG, "exception in onUserTalkStateUpdated: " + e); + return; + } + if (user != null && user.getSession() == selfSession) { + // Manually set button selection colour when we receive a talk state update. + // This allows representation of talk state when using hot corners and PTT toggle. + switch (user.getTalkState()) { + case TALKING: + case SHOUTING: + case WHISPERING: + mTalkButton.setPressed(true); + break; + case PASSIVE: + mTalkButton.setPressed(false); + break; + } + } + } + + @Override + public void onUserStateUpdated(IUser user) { + if (getService() == null || !getService().isConnected()) { + return; + } + int selfSession; + try { + selfSession = getService().HumlaSession().getSessionId(); + } catch (IllegalStateException e) { + Log.d(TAG, "exception in onUserStateUpdated: " + e); + return; + } + if (user != null && user.getSession() == selfSession) { + configureInput(); + } + } + + @Override + public void onVoiceTargetChanged(VoiceTargetMode mode) { + configureTargetPanel(); + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_channel, container, false); + mViewPager = (ViewPager) view.findViewById(R.id.channel_view_pager); + mTabStrip = (PagerTabStrip) view.findViewById(R.id.channel_tab_strip); + if(mTabStrip != null) { + int[] attrs = new int[] { android.R.attr.colorPrimary, android.R.attr.textColorPrimaryInverse }; + TypedArray a = getActivity().obtainStyledAttributes(attrs); + int titleStripBackground = a.getColor(0, -1); + int titleStripColor = a.getColor(1, -1); + a.recycle(); + + mTabStrip.setTextColor(titleStripColor); + mTabStrip.setTabIndicatorColor(titleStripColor); + mTabStrip.setBackgroundColor(titleStripBackground); + mTabStrip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); + } + + mTalkView = view.findViewById(R.id.pushtotalk_view); + mTalkButton = (Button) view.findViewById(R.id.pushtotalk); + mTalkButton.setOnTouchListener(new View.OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + if (getService() != null) { + getService().onTalkKeyDown(); + } + break; + case MotionEvent.ACTION_UP: + if (getService() != null) { + getService().onTalkKeyUp(); + } + break; + } + return true; + } + }); + mTargetPanel = view.findViewById(R.id.target_panel); + mTargetPanelCancel = (ImageView) view.findViewById(R.id.target_panel_cancel); + mTargetPanelCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (getService() == null || !getService().isConnected()) + return; + + IHumlaSession session = getService().HumlaSession(); + if (session.getVoiceTargetMode() == VoiceTargetMode.WHISPER) { + byte target = session.getVoiceTargetId(); + session.setVoiceTargetId((byte) 0); + session.unregisterWhisperTarget(target); + } + } + }); + mTargetPanelText = (TextView) view.findViewById(R.id.target_panel_warning); + configureInput(); + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + preferences.registerOnSharedPreferenceChangeListener(this); + + if(mViewPager != null) { // Phone + ChannelFragmentPagerAdapter pagerAdapter = new ChannelFragmentPagerAdapter(getChildFragmentManager()); + mViewPager.setAdapter(pagerAdapter); + } else { // Tablet + ChannelListFragment listFragment = new ChannelListFragment(); + Bundle listArgs = new Bundle(); + listArgs.putBoolean("pinned", isShowingPinnedChannels()); + listFragment.setArguments(listArgs); + ChannelChatFragment chatFragment = new ChannelChatFragment(); + + getChildFragmentManager().beginTransaction() + .replace(R.id.list_fragment, listFragment) + .replace(R.id.chat_fragment, chatFragment) + .commit(); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.channel_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + Settings settings = Settings.getInstance(getActivity()); + int itemId = item.getItemId(); + if (itemId == R.id.menu_input_voice) { + settings.setInputMethod(Settings.ARRAY_INPUT_METHOD_VOICE); + return true; + } else if (itemId == R.id.menu_input_ptt) { + settings.setInputMethod(Settings.ARRAY_INPUT_METHOD_PTT); + return true; + } else if (itemId == R.id.menu_input_continuous) { + settings.setInputMethod(Settings.ARRAY_INPUT_METHOD_CONTINUOUS); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onPause() { + super.onPause(); + if (getService() != null && getService().isConnected() && + !Settings.getInstance(getActivity()).isPushToTalkToggle()) { + // XXX: This ensures that push to talk is disabled when we pause. + // We don't want to leave the talk state active if the fragment is paused while pressed. + getService().HumlaSession().setTalkingState(false); + } + } + + @Override + public void onDestroy() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + preferences.unregisterOnSharedPreferenceChangeListener(this); + super.onDestroy(); + } + + @Override + public IHumlaObserver getServiceObserver() { + return mObserver; + } + + @Override + public void onServiceBound(IHumlaService service) { + super.onServiceBound(service); + if (service.getConnectionState() == HumlaService.ConnectionState.CONNECTED) { + configureTargetPanel(); + configureInput(); + } + } + + private void configureTargetPanel() { + if (getService() == null || !getService().isConnected()) { + return; + } + + IHumlaSession session = getService().HumlaSession(); + VoiceTargetMode mode = session.getVoiceTargetMode(); + if (mode == VoiceTargetMode.WHISPER) { + WhisperTarget target = session.getWhisperTarget(); + mTargetPanel.setVisibility(View.VISIBLE); + mTargetPanelText.setText(getString(R.string.shout_target, target.getName())); + } else { + mTargetPanel.setVisibility(View.GONE); + } + } + + /** + * @return true if the channel fragment is set to display only the user's pinned channels. + */ + private boolean isShowingPinnedChannels() { + return getArguments() != null && + getArguments().getBoolean("pinned"); + } + + /** + * Configures the fragment in accordance with the user's interface preferences. + */ + private void configureInput() { + Settings settings = Settings.getInstance(getActivity()); + + ViewGroup.LayoutParams params = mTalkView.getLayoutParams(); + params.height = settings.getPTTButtonHeight(); + mTalkButton.setLayoutParams(params); + + boolean muted = false; + if (getService() != null && getService().isConnected()) { + IUser self = null; + try { + self = getService().HumlaSession().getSessionUser(); + } catch (HumlaDisconnectedException|IllegalStateException e) { + Log.d(TAG, "exception in configureInput: " + e); + } + muted = self == null || self.isMuted() || self.isSuppressed() || self.isSelfMuted(); + } + boolean showPttButton = + !muted && + settings.isPushToTalkButtonShown() && + settings.getInputMethod().equals(Settings.ARRAY_INPUT_METHOD_PTT); + setTalkButtonHidden(!showPttButton); + } + + private void setTalkButtonHidden(final boolean hidden) { + mTalkView.setVisibility(hidden ? View.GONE : View.VISIBLE); + mTalkButtonHidden = hidden; + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if(Settings.PREF_INPUT_METHOD.equals(key) + || Settings.PREF_PUSH_BUTTON_HIDE_KEY.equals(key) + || Settings.PREF_PTT_BUTTON_HEIGHT.equals(key)) + configureInput(); + } + + @Override + public ChatTarget getChatTarget() { + return mChatTarget; + } + + @Override + public void setChatTarget(ChatTarget target) { + mChatTarget = target; + for(OnChatTargetSelectedListener listener : mChatTargetListeners) + listener.onChatTargetSelected(target); + } + + @Override + public void registerChatTargetListener(OnChatTargetSelectedListener listener) { + mChatTargetListeners.add(listener); + } + + @Override + public void unregisterChatTargetListener(OnChatTargetSelectedListener listener) { + mChatTargetListeners.remove(listener); + } + + private class ChannelFragmentPagerAdapter extends FragmentPagerAdapter { + + public ChannelFragmentPagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int i) { + Fragment fragment = null; + Bundle args = new Bundle(); + switch (i) { + case 0: + fragment = new ChannelListFragment(); + args.putBoolean("pinned", isShowingPinnedChannels()); + break; + case 1: + fragment = new ChannelChatFragment(); + break; + } + fragment.setArguments(args); + return fragment; + } + + @Override + public CharSequence getPageTitle(int position) { + switch (position) { + case 0: + return getString(R.string.channel).toUpperCase(); + case 1: + return getString(R.string.chat).toUpperCase(); + default: + return null; + } + } + + @Override + public int getCount() { + return 2; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelListAdapter.java b/app/src/main/java/se/lublin/mumla/channel/ChannelListAdapter.java new file mode 100644 index 00000000..e32b87fd --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelListAdapter.java @@ -0,0 +1,594 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Drawable.ConstantState; +import android.os.RemoteException; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.fragment.app.FragmentManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; + +import se.lublin.humla.HumlaService; +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.Server; +import se.lublin.humla.model.TalkState; +import se.lublin.humla.util.HumlaDisconnectedException; +import se.lublin.mumla.R; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.drawable.CircleDrawable; +import se.lublin.mumla.service.MumlaService; + +/** + * Created by andrew on 31/07/13. + */ +public class ChannelListAdapter extends RecyclerView.Adapter implements UserMenu.IUserLocalStateListener { + private static final String TAG = ChannelListAdapter.class.getName(); + + // Set particular bits to make the integer-based model item ids unique. + public static final long CHANNEL_ID_MASK = (0x1L << 32); + public static final long USER_ID_MASK = (0x1L << 33); + + private Context mContext; + private IHumlaService mService; + private MumlaDatabase mDatabase; + private List mRootChannels; + private List mNodes; + /** + * A mapping of user-set channel expansions. + * If a key is not mapped, default to hiding empty channels. + */ + private HashMap mExpandedChannels; + private OnUserClickListener mUserClickListener; + private OnChannelClickListener mChannelClickListener; + private boolean mShowChannelUserCount; + private final FragmentManager mFragmentManager; + + public ChannelListAdapter(Context context, IHumlaService service, MumlaDatabase database, + FragmentManager fragmentManager, boolean showPinnedOnly, + boolean showChannelUserCount) throws RemoteException { + setHasStableIds(true); + mContext = context; + mService = service; + mDatabase = database; + mFragmentManager = fragmentManager; + mShowChannelUserCount = showChannelUserCount; + + mRootChannels = new ArrayList(); + if(showPinnedOnly) { + mRootChannels = mDatabase.getPinnedChannels(mService.getTargetServer().getId()); + } else { + mRootChannels.add(0); + } + + // Construct channel tree + mNodes = new LinkedList(); + mExpandedChannels = new HashMap(); + updateChannels(); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + LayoutInflater inflater = (LayoutInflater) + mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(viewType, viewGroup, false); + if (viewType == R.layout.channel_row) { + return new ChannelViewHolder(view); + } else if (viewType == R.layout.channel_user_row) { + return new UserViewHolder(view); + } + return null; + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) { + final Node node = mNodes.get(position); + if (node.isChannel()) { + final IChannel channel = node.getChannel(); + final ChannelViewHolder cvh = (ChannelViewHolder) viewHolder; + cvh.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mChannelClickListener != null) { + mChannelClickListener.onChannelClick(channel); + } + } + }); + + final boolean expandUsable = channel.getSubchannels().size() > 0 || + channel.getSubchannelUserCount() > 0; + cvh.mChannelExpandToggle.setImageResource(node.isExpanded() ? + R.drawable.ic_action_expanded : R.drawable.ic_action_collapsed); + cvh.mChannelExpandToggle.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + mExpandedChannels.put(channel.getId(), !node.isExpanded()); + updateChannels(); // FIXME: very inefficient. + notifyDataSetChanged(); + } + }); + // Dim channel expand toggle when no subchannels exist + cvh.mChannelExpandToggle.setEnabled(expandUsable); + cvh.mChannelExpandToggle.setVisibility(expandUsable ? View.VISIBLE : View.INVISIBLE); + + cvh.mChannelName.setText(channel.getName()); + + int nameTypeface = Typeface.NORMAL; + if (mService != null && mService.isConnected()) { + IHumlaSession session = mService.HumlaSession(); + IChannel ourChan = null; + try { + ourChan = session.getSessionChannel(); + } catch(IllegalStateException e) { + Log.d(TAG, "exception in onBindViewHolder: " + e); + } + if (ourChan != null) { + if (channel.equals(ourChan)) { + nameTypeface |= Typeface.BOLD; + // Always italicize our current channel if it has a link. + if (channel.getLinks().size() > 0) { + nameTypeface |= Typeface.ITALIC; + } + } + // Italicize channels in a link with our current channel. + if (channel.getLinks().contains(ourChan)) { + nameTypeface |= Typeface.ITALIC; + } + } + } + cvh.mChannelName.setTypeface(null, nameTypeface); + + if (mShowChannelUserCount) { + cvh.mChannelUserCount.setVisibility(View.VISIBLE); + int userCount = channel.getSubchannelUserCount(); + cvh.mChannelUserCount.setText(String.format("%d", userCount)); + } else { + cvh.mChannelUserCount.setVisibility(View.GONE); + } + + // Pad the view depending on channel's nested level. + DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); + float margin = node.getDepth() * TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25, metrics); + cvh.mChannelHolder.setPadding((int) margin, + cvh.mChannelHolder.getPaddingTop(), + cvh.mChannelHolder.getPaddingRight(), + cvh.mChannelHolder.getPaddingBottom()); + + cvh.mJoinButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mService != null && mService.isConnected()) { + mService.HumlaSession().joinChannel(channel.getId()); + } + } + }); + + cvh.mMoreButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + ChannelMenu menu = new ChannelMenu(mContext, channel, mService, mDatabase, mFragmentManager); + menu.showPopup(v); + } + }); + + cvh.itemView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + cvh.mMoreButton.performClick(); + return true; + } + }); + } else if (node.isUser()) { + final IUser user = node.getUser(); + final UserViewHolder uvh = (UserViewHolder) viewHolder; + uvh.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mUserClickListener != null) { + mUserClickListener.onUserClick(user); + } + } + }); + + uvh.mUserName.setText(user.getName()); + + final int typefaceStyle; + + int selfSession = -1; + try { + if (mService != null) { + selfSession = mService.HumlaSession().getSessionId(); + } + } catch (HumlaDisconnectedException|IllegalStateException e) { + Log.d(TAG, "exception in onBindViewHolder: " + e); + } + + if (mService != null && mService.isConnected() && user.getSession() == selfSession) { + typefaceStyle = Typeface.BOLD; + } else { + typefaceStyle = Typeface.NORMAL; + } + uvh.mUserName.setTypeface(null, typefaceStyle); + + uvh.mUserTalkHighlight.setImageDrawable(getTalkStateDrawable(user)); + + // Pad the view depending on channel's nested level. + DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); + float margin = (node.getDepth() + 1) * TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25, metrics); + uvh.mUserHolder.setPadding((int) margin, + uvh.mUserHolder.getPaddingTop(), + uvh.mUserHolder.getPaddingRight(), + uvh.mUserHolder.getPaddingBottom()); + + uvh.mMoreButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + UserMenu menu = new UserMenu(mContext, user, (MumlaService) mService, + mFragmentManager, ChannelListAdapter.this); + menu.showPopup(v); + } + }); + + uvh.itemView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + uvh.mMoreButton.performClick(); + return true; + } + }); + } + } + + @Override + public int getItemCount() { + return mNodes.size(); + } + + @Override + public int getItemViewType(int position) { + Node node = mNodes.get(position); + if (node.isChannel()) { + return R.layout.channel_row; + } else if (node.isUser()) { + return R.layout.channel_user_row; + } else { + return 0; + } + } + + @Override + public long getItemId(int position) { + try { + return mNodes.get(position).getId(); + } catch (RemoteException e) { + e.printStackTrace(); + } + return -1; + } + + /** + * Updates the channel tree model. + * To be used after any channel tree modifications. + */ + public void updateChannels() { + if (mService == null || !mService.isConnected()) { + return; + } + + IHumlaSession session = mService.HumlaSession(); + mNodes.clear(); + try { + for (int cid : mRootChannels) { + IChannel channel = session.getChannel(cid); + if (channel != null) { + constructNodes(null, channel, 0, mNodes); + } + } + } catch (IllegalStateException e) { + Log.d(TAG, "exception in updateChannels: " + e); + } + } + + /** + * Update a user's state icon + * @param user The user to update. + * @param view The view containing this adapter. + */ + public void updateUserStates(IUser user, RecyclerView view) { + long itemId = user.getSession() | USER_ID_MASK; + UserViewHolder uvh = (UserViewHolder) view.findViewHolderForItemId(itemId); + if (uvh != null) { + Drawable newState = getTalkStateDrawable(user); + ConstantState state = uvh.mUserTalkHighlight.getDrawable().getCurrent().getConstantState(); + if (state != null && !state.equals(newState.getConstantState())) { + uvh.mUserTalkHighlight.setImageDrawable(newState); + } + } + } + + private Drawable getTalkStateDrawable(IUser user) { + Resources resources = mContext.getResources(); + if (user.isSelfDeafened()) { + return resources.getDrawable(R.drawable.outline_circle_deafened); + } else if (user.isDeafened()) { + return resources.getDrawable(R.drawable.outline_circle_server_deafened); + } else if (user.isSelfMuted()) { + return resources.getDrawable(R.drawable.outline_circle_muted); + } else if (user.isMuted()) { + return resources.getDrawable(R.drawable.outline_circle_server_muted); + } else if (user.isSuppressed()) { + return resources.getDrawable(R.drawable.outline_circle_suppressed); + } else if (user.getTalkState() == TalkState.TALKING + || user.getTalkState() == TalkState.SHOUTING + || user.getTalkState() == TalkState.WHISPERING) { + // TODO whisper and shouting? + return resources.getDrawable(R.drawable.outline_circle_talking_on); + } else { + // Passive drawables + if (user.getTexture() != null) { + // FIXME: cache bitmaps + Bitmap bitmap = BitmapFactory.decodeByteArray(user.getTexture(), 0, user.getTexture().length); + // yes, decoding can fail + if (bitmap != null) { + return new CircleDrawable(mContext.getResources(), bitmap); + } + } + } + // "default" symbol, used also if bitmap decoding fails + return resources.getDrawable(R.drawable.outline_circle_talking_off); + } + + public int getUserPosition(int session) { + long itemId = session | USER_ID_MASK; + for (int i = 0; i < mNodes.size(); i++) { + Node node = mNodes.get(i); + try { + if (node.getId() == itemId) { + return i; + } + } catch (RemoteException e) { + e.printStackTrace(); + } + } + return -1; + } + + public int getChannelPosition(int channelId) { + long itemId = channelId | CHANNEL_ID_MASK; + for (int i = 0; i < mNodes.size(); i++) { + Node node = mNodes.get(i); + try { + if (node.getId() == itemId) { + return i; + } + } catch (RemoteException e) { + e.printStackTrace(); + } + } + return -1; + } + + public void setOnUserClickListener(OnUserClickListener listener) { + mUserClickListener = listener; + } + + public void setOnChannelClickListener(OnChannelClickListener listener) { + mChannelClickListener = listener; + } + + /** + * Sets whether to show the channel user count in a channel row. + */ + public void setShowChannelUserCount(boolean showUserCount) { + mShowChannelUserCount = showUserCount; + notifyDataSetChanged(); + } + + /** + * Recursively creates a list of {@link Node}s representing the channel hierarchy. + * @param parent The parent node to propagate under. + * @param channel The parent channel. + * @param depth The current depth of the subtree. + * @param nodes An accumulator to store generated nodes into. + */ + private void constructNodes(Node parent, IChannel channel, int depth, + List nodes) { + Node channelNode = new Node(parent, depth, channel); + nodes.add(channelNode); + + Boolean expandSetting = mExpandedChannels.get(channel.getId()); + if ((expandSetting == null && channel.getSubchannelUserCount() == 0) + || (expandSetting != null && !expandSetting)) { + channelNode.setExpanded(false); + return; // Skip adding children of contracted/empty channels. + } + + for (IUser user : channel.getUsers()) { + if (user == null) { + continue; + } + nodes.add(new Node(channelNode, depth, user)); + } + for (IChannel subc : channel.getSubchannels()) { + constructNodes(channelNode, subc, depth + 1, nodes); + } + } + + /** + * Changes the service backing the adapter. Updates the list as well. + * @param service The new service to retrieve channels from. + */ + public void setService(IHumlaService service) { + mService = service; + if (service.getConnectionState() == HumlaService.ConnectionState.CONNECTED) { + updateChannels(); + notifyDataSetChanged(); + } + } + + @Override + public void onLocalUserStateUpdated(final IUser user) { + notifyDataSetChanged(); + + // Add or remove registered user from local mute history + final Server server = mService.getTargetServer(); + + if (user.getUserId() >= 0 && server.isSaved()) { + new Thread(new Runnable() { + @Override + public void run() { + if (user.isLocalMuted()) { + mDatabase.addLocalMutedUser(server.getId(), user.getUserId()); + } else { + mDatabase.removeLocalMutedUser(server.getId(), user.getUserId()); + } + if (user.isLocalIgnored()) { + mDatabase.addLocalIgnoredUser(server.getId(), user.getUserId()); + } else { + mDatabase.removeLocalIgnoredUser(server.getId(), user.getUserId()); + } + } + }).start(); + } + } + + private static class UserViewHolder extends RecyclerView.ViewHolder { + public LinearLayout mUserHolder; + public TextView mUserName; +// public ImageView mUserAvatar; + public ImageView mUserTalkHighlight; + public ImageView mMoreButton; + + public UserViewHolder(View itemView) { + super(itemView); + mUserHolder = (LinearLayout) itemView.findViewById(R.id.user_row_title); + mUserTalkHighlight = (ImageView) itemView.findViewById(R.id.user_row_talk_highlight); + mUserName = (TextView) itemView.findViewById(R.id.user_row_name); + mMoreButton = (ImageView) itemView.findViewById(R.id.user_row_more); + } + } + + private static class ChannelViewHolder extends RecyclerView.ViewHolder { + public LinearLayout mChannelHolder; + public ImageView mChannelExpandToggle; + public TextView mChannelName; + public TextView mChannelUserCount; + public ImageView mJoinButton; + public ImageView mMoreButton; + + public ChannelViewHolder(View itemView) { + super(itemView); + mChannelHolder = (LinearLayout) itemView.findViewById(R.id.channel_row_title); + mChannelExpandToggle = (ImageView) itemView.findViewById(R.id.channel_row_expand); + mChannelName = (TextView) itemView.findViewById(R.id.channel_row_name); + mChannelUserCount = (TextView) itemView.findViewById(R.id.channel_row_count); + mJoinButton = (ImageView) itemView.findViewById(R.id.channel_row_join); + mMoreButton = (ImageView) itemView.findViewById(R.id.channel_row_more); + } + } + + /** + * An arbitrary node in the channel-user hierarchy. + * Can be either a channel or user. + */ + private static class Node { + private Node mParent; + private IChannel mChannel; + private IUser mUser; + private int mDepth; + private boolean mExpanded; + + public Node(Node parent, int depth, IChannel channel) { + mParent = parent; + mChannel = channel; + mDepth = depth; + mExpanded = true; + } + + public Node(Node parent, int depth, IUser user) { + mParent = parent; + mUser = user; + mDepth = depth; + } + + public boolean isChannel() { + return mChannel != null; + } + + public boolean isUser() { + return mUser != null; + } + + public Node getParent() { + return mParent; + } + + public IChannel getChannel() { + return mChannel; + } + + public IUser getUser() { + return mUser; + } + + public Long getId() throws RemoteException { + // Apply flags to differentiate integer-length identifiers + if (isChannel()) { + return CHANNEL_ID_MASK | mChannel.getId(); + } else if (isUser()) { + return USER_ID_MASK | mUser.getSession(); + } + return null; + } + + public int getDepth() { + return mDepth; + } + + public boolean isExpanded() { + return mExpanded; + } + + public void setExpanded(boolean expanded) { + mExpanded = expanded; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelListFragment.java b/app/src/main/java/se/lublin/mumla/channel/ChannelListFragment.java new file mode 100644 index 00000000..8ff30c0c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelListFragment.java @@ -0,0 +1,417 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import static android.content.Context.RECEIVER_NOT_EXPORTED; + +import android.app.Activity; +import android.app.SearchManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.database.CursorWrapper; +import android.graphics.PorterDuff; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.SearchView; +import androidx.core.view.MenuItemCompat; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.util.HumlaDisconnectedException; +import se.lublin.humla.util.HumlaException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.humla.util.IHumlaObserver; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.DatabaseProvider; +import se.lublin.mumla.util.HumlaServiceFragment; + +public class ChannelListFragment extends HumlaServiceFragment implements OnChannelClickListener, OnUserClickListener, SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = ChannelListFragment.class.getName(); + + private IHumlaObserver mServiceObserver = new HumlaObserver() { + @Override + public void onDisconnected(HumlaException e) { + mChannelView.setAdapter(null); + } + + @Override + public void onUserJoinedChannel(IUser user, IChannel newChannel, IChannel oldChannel) { + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + + if (getService() == null || !getService().isConnected()) { + return; + } + + int selfSession; + try { + selfSession = getService().HumlaSession().getSessionId(); + } catch (HumlaDisconnectedException|IllegalStateException e) { + Log.d(TAG, "exception in onUserJoinedChannel: " + e); + return; + } + + if (user.getSession() == selfSession) { + scrollToChannel(newChannel.getId()); + } + } + + @Override + public void onChannelAdded(IChannel channel) { + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + } + + @Override + public void onChannelRemoved(IChannel channel) { + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + } + + @Override + public void onChannelStateUpdated(IChannel channel) { + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + } + + @Override + public void onUserConnected(IUser user) { + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + } + + @Override + public void onUserRemoved(IUser user, String reason) { + // If we are the user being removed, don't update the channel list. + // We won't be in a synchronized state. + if (getService() == null || !getService().isConnected()) { + return; + } + + mChannelListAdapter.updateChannels(); + mChannelListAdapter.notifyDataSetChanged(); + } + + @Override + public void onUserStateUpdated(IUser user) { + mChannelListAdapter.updateUserStates(user, mChannelView); + getActivity().supportInvalidateOptionsMenu(); // Update self mute/deafen state + } + + @Override + public void onUserTalkStateUpdated(IUser user) { + mChannelListAdapter.updateUserStates(user, mChannelView); + } + }; + + private BroadcastReceiver mBluetoothReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if(getActivity() != null) + getActivity().supportInvalidateOptionsMenu(); // Update bluetooth menu item + } + }; + + private RecyclerView mChannelView; + private ChannelListAdapter mChannelListAdapter; + private ChatTargetProvider mTargetProvider; + private DatabaseProvider mDatabaseProvider; + private ActionMode mActionMode; + private Settings mSettings; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mTargetProvider = (ChatTargetProvider) getParentFragment(); + } catch (ClassCastException e) { + throw new ClassCastException(getParentFragment().toString()+" must implement ChatTargetProvider"); + } + try { + mDatabaseProvider = (DatabaseProvider) getActivity(); + } catch (ClassCastException e) { + throw new ClassCastException(getActivity().toString()+" must implement DatabaseProvider"); + } + mSettings = Settings.getInstance(activity); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + preferences.registerOnSharedPreferenceChangeListener(this); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_channel_list, container, false); + mChannelView = (RecyclerView) view.findViewById(R.id.channelUsers); + mChannelView.setLayoutManager(new LinearLayoutManager(getActivity())); + + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + registerForContextMenu(mChannelView); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + getActivity().registerReceiver(mBluetoothReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED), RECEIVER_NOT_EXPORTED); + } else { + getActivity().registerReceiver(mBluetoothReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED)); + } + } + + @Override + public void onDetach() { + getActivity().unregisterReceiver(mBluetoothReceiver); + super.onDetach(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + preferences.unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public IHumlaObserver getServiceObserver() { + return mServiceObserver; + } + + @Override + public void onServiceBound(IHumlaService service) { + try { + if (mChannelListAdapter == null) { + setupChannelList(); + } else { + mChannelListAdapter.setService(service); + } + } catch (RemoteException e) { + e.printStackTrace(); + } + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + + MenuItem muteItem = menu.findItem(R.id.menu_mute_button); + MenuItem deafenItem = menu.findItem(R.id.menu_deafen_button); + + if(getService() != null && getService().isConnected()) { + IHumlaSession session = getService().HumlaSession(); + + // Color the action bar icons to the primary text color of the theme, TODO move this elsewhere + int foregroundColor = getActivity().getTheme().obtainStyledAttributes(new int[]{android.R.attr.textColorPrimaryInverse}).getColor(0, -1); + + IUser self = session.getSessionUser(); + if (self != null) { + muteItem.setIcon(self.isSelfMuted() ? R.drawable.ic_action_microphone_muted : R.drawable.ic_action_microphone); + deafenItem.setIcon(self.isSelfDeafened() ? R.drawable.ic_action_audio_muted : R.drawable.ic_action_audio); + muteItem.getIcon().mutate().setColorFilter(foregroundColor, PorterDuff.Mode.MULTIPLY); + deafenItem.getIcon().mutate().setColorFilter(foregroundColor, PorterDuff.Mode.MULTIPLY); + } + + MenuItem bluetoothItem = menu.findItem(R.id.menu_bluetooth); + bluetoothItem.setChecked(session.usingBluetoothSco()); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.fragment_channel_list, menu); + + MenuItem searchItem = menu.findItem(R.id.menu_search); + SearchManager searchManager = (SearchManager) getActivity().getSystemService(Context.SEARCH_SERVICE); + + final SearchView searchView = (SearchView)MenuItemCompat.getActionView(searchItem); + searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName())); + searchView.setOnSuggestionListener(new SearchView.OnSuggestionListener() { + @Override + public boolean onSuggestionSelect(int i) { + return false; + } + + @Override + public boolean onSuggestionClick(int i) { + if (getService() == null || !getService().isConnected()) + return false; + CursorWrapper cursor = (CursorWrapper) searchView.getSuggestionsAdapter().getItem(i); + int typeColumn = cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA); + int dataIdColumn = cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_DATA); + String itemType = cursor.getString(typeColumn); + int itemId = cursor.getInt(dataIdColumn); + + IHumlaSession session = getService().HumlaSession(); + if(ChannelSearchProvider.INTENT_DATA_CHANNEL.equals(itemType)) { + if(session.getSessionChannel().getId() != itemId) { + session.joinChannel(itemId); + } else { + scrollToChannel(itemId); + } + return true; + } else if(ChannelSearchProvider.INTENT_DATA_USER.equals(itemType)) { + scrollToUser(itemId); + return true; + } + return false; + } + }); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (getService() == null || !getService().isConnected()) + return super.onOptionsItemSelected(item); + + IHumlaSession session = getService().HumlaSession(); + int itemId = item.getItemId(); + if (itemId == R.id.menu_mute_button) { + IUser self = session.getSessionUser(); + if (self != null) { + boolean muted = !self.isSelfMuted(); + boolean deafened = self.isSelfDeafened(); + deafened &= muted; // Undeafen if mute is off + session.setSelfMuteDeafState(muted, deafened); + } + + getActivity().supportInvalidateOptionsMenu(); + return true; + } else if (itemId == R.id.menu_deafen_button) { + IUser self = session.getSessionUser(); + if (self != null) { + boolean deafened = !self.isSelfDeafened(); + session.setSelfMuteDeafState(deafened, deafened); + } + + getActivity().supportInvalidateOptionsMenu(); + return true; + } else if (itemId == R.id.menu_search) { + return false; + } else if (itemId == R.id.menu_bluetooth) { + item.setChecked(!item.isChecked()); + if (item.isChecked()) { + session.enableBluetoothSco(); + } else { + session.disableBluetoothSco(); + } + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void setupChannelList() throws RemoteException { + mChannelListAdapter = new ChannelListAdapter(getActivity(), getService(), + mDatabaseProvider.getDatabase(), getChildFragmentManager(), + isShowingPinnedChannels(), mSettings.shouldShowUserCount()); + mChannelListAdapter.setOnChannelClickListener(this); + mChannelListAdapter.setOnUserClickListener(this); + mChannelView.setAdapter(mChannelListAdapter); + mChannelListAdapter.notifyDataSetChanged(); + } + + /** + * Scrolls to the passed channel. + */ + public void scrollToChannel(int channelId) { + int channelPosition = mChannelListAdapter.getChannelPosition(channelId); + mChannelView.scrollToPosition(channelPosition); + } + /** + * Scrolls to the passed user. + */ + public void scrollToUser(int userId) { + int userPosition = mChannelListAdapter.getUserPosition(userId); + mChannelView.scrollToPosition(userPosition); + } + + private boolean isShowingPinnedChannels() { + return getArguments().getBoolean("pinned"); + } + @Override + public void onChannelClick(IChannel channel) { + if (mTargetProvider.getChatTarget() != null && + channel.equals(mTargetProvider.getChatTarget().getChannel()) && + mActionMode != null) { + // Dismiss action mode if double pressed. FIXME: use list view selection instead? + mActionMode.finish(); + } else { + ActionMode.Callback cb = new ChatTargetActionModeCallback(mTargetProvider, new ChatTargetProvider.ChatTarget(channel)) { + @Override + public void onDestroyActionMode(ActionMode actionMode) { + super.onDestroyActionMode(actionMode); + mActionMode = null; + } + }; + mActionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(cb); + } + } + + @Override + public void onUserClick(IUser user) { + if (mTargetProvider.getChatTarget() != null && + user.equals(mTargetProvider.getChatTarget().getUser()) && + mActionMode != null) { + // Dismiss action mode if double pressed. FIXME: use list view selection instead? + mActionMode.finish(); + } else { + ActionMode.Callback cb = new ChatTargetActionModeCallback(mTargetProvider, new ChatTargetProvider.ChatTarget(user)) { + @Override + public void onDestroyActionMode(ActionMode actionMode) { + super.onDestroyActionMode(actionMode); + mActionMode = null; + } + }; + mActionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(cb); + } + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (Settings.PREF_SHOW_USER_COUNT.equals(key) && mChannelListAdapter != null) { + mChannelListAdapter.setShowChannelUserCount(mSettings.shouldShowUserCount()); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelMenu.java b/app/src/main/java/se/lublin/mumla/channel/ChannelMenu.java new file mode 100644 index 00000000..d35539a4 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelMenu.java @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2015 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.Toast; + +import androidx.appcompat.widget.PopupMenu; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.Server; +import se.lublin.humla.model.WhisperTargetChannel; +import se.lublin.humla.net.Permissions; +import se.lublin.humla.util.VoiceTargetMode; +import se.lublin.mumla.R; +import se.lublin.mumla.channel.comment.ChannelDescriptionFragment; +import se.lublin.mumla.db.MumlaDatabase; + +/** + * Created by andrew on 22/11/15. + */ +public class ChannelMenu implements PermissionsPopupMenu.IOnMenuPrepareListener, PopupMenu.OnMenuItemClickListener { + private static final String TAG = ChannelMenu.class.getName(); + + private final Context mContext; + private final IChannel mChannel; + private final IHumlaService mService; + private final MumlaDatabase mDatabase; + private final FragmentManager mFragmentManager; + + public ChannelMenu(Context context, IChannel channel, IHumlaService service, + MumlaDatabase database, FragmentManager fragmentManager) { + mContext = context; + mChannel = channel; + mService = service; + mDatabase = database; + mFragmentManager = fragmentManager; + } + + @Override + public void onMenuPrepare(Menu menu, int permissions) { + // TODO This breaks uMurmur ACL. Put in a fix based on server version perhaps? + //menu.getMenu().findItem(R.id.menu_channel_add) + // .setVisible((permissions & (Permissions.MakeChannel | Permissions.MakeTempChannel)) > 0); + menu.findItem(R.id.context_channel_edit).setVisible((permissions & Permissions.Write) > 0); + menu.findItem(R.id.context_channel_remove).setVisible((permissions & Permissions.Write) > 0); + menu.findItem(R.id.context_channel_view_description) + .setVisible(mChannel.getDescription() != null || + mChannel.getDescriptionHash() != null); + Server server = mService.getTargetServer(); + if(server != null) { + menu.findItem(R.id.context_channel_pin) + .setChecked(mDatabase.isChannelPinned(server.getId(), mChannel.getId())); + } + if (mService.isConnected()) { + IChannel ourChan = null; + try { + ourChan = mService.HumlaSession().getSessionChannel(); + } catch(IllegalStateException e) { + Log.d(TAG, "exception in onMenuPrepare: " + e); + } + if (ourChan != null) { + menu.findItem(R.id.context_channel_link).setChecked(mChannel.getLinks().contains(ourChan)); + } + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + if (!mService.isConnected()) + return false; + + int itemId = item.getItemId(); + if (itemId == R.id.context_channel_join) { + mService.HumlaSession().joinChannel(mChannel.getId()); + } else if (itemId == R.id.context_channel_add || itemId == R.id.context_channel_edit) { + Bundle args = new Bundle(); + if (itemId == R.id.context_channel_add) { + args.putInt("parent", mChannel.getId()); + args.putBoolean("adding", true); + } else { + args.putInt("channel", mChannel.getId()); + args.putBoolean("adding", false); + } + ChannelEditFragment addFragment = new ChannelEditFragment(); + addFragment.setArguments(args); + addFragment.show(mFragmentManager, "ChannelAdd"); + } else if (itemId == R.id.context_channel_remove) { + new MaterialAlertDialogBuilder(mContext) + .setTitle(R.string.confirm) + .setMessage(R.string.confirm_delete_channel) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (mService.isConnected()) { + mService.HumlaSession().removeChannel(mChannel.getId()); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else if (itemId == R.id.context_channel_view_description) { + Bundle commentArgs = new Bundle(); + commentArgs.putInt("channel", mChannel.getId()); + commentArgs.putString("comment", mChannel.getDescription()); + commentArgs.putBoolean("editing", false); + DialogFragment commentFragment = (DialogFragment) Fragment.instantiate(mContext, + ChannelDescriptionFragment.class.getName(), commentArgs); + commentFragment.show(mFragmentManager, ChannelDescriptionFragment.class.getName()); + } else if (itemId == R.id.context_channel_pin) { + long serverId = mService.getTargetServer().getId(); + boolean pinned = mDatabase.isChannelPinned(serverId, mChannel.getId()); + if (!pinned) mDatabase.addPinnedChannel(serverId, mChannel.getId()); + else mDatabase.removePinnedChannel(serverId, mChannel.getId()); + } else if (itemId == R.id.context_channel_link) { + IChannel channel = mService.HumlaSession().getSessionChannel(); + if (!item.isChecked()) { + mService.HumlaSession().linkChannels(channel, mChannel); + } else { + mService.HumlaSession().unlinkChannels(channel, mChannel); + } + } else if (itemId == R.id.context_channel_unlink_all) { + mService.HumlaSession().unlinkAllChannels(mChannel); + } else if (itemId == R.id.context_channel_shout) { + LinearLayout layout = new LinearLayout(mContext); + layout.setOrientation(LinearLayout.VERTICAL); + final CheckBox subchannelBox = new CheckBox(mContext); + subchannelBox.setText(R.string.shout_include_subchannels); + layout.addView(subchannelBox); + final CheckBox linkedBox = new CheckBox(mContext); + linkedBox.setText(R.string.shout_include_linked); + layout.addView(linkedBox); + new MaterialAlertDialogBuilder(mContext) + .setTitle(R.string.shout_configure) + .setView(layout) + .setPositiveButton(R.string.confirm, (dialog, which) -> { + if (!mService.isConnected()) { + return; + } + IHumlaSession session = mService.HumlaSession(); + // Unregister any existing voice target. + if (session.getVoiceTargetMode() == VoiceTargetMode.WHISPER) { + session.unregisterWhisperTarget(session.getVoiceTargetId()); + } + WhisperTargetChannel channelTarget = new WhisperTargetChannel(mChannel, linkedBox.isChecked(), subchannelBox.isChecked(), null); + byte id = session.registerWhisperTarget(channelTarget); + if (id > 0) { + session.setVoiceTargetId(id); + } else { + Toast.makeText(mContext, R.string.shout_failed, Toast.LENGTH_LONG).show(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + return false; + } + return true; + } + + public void showPopup(View anchor) { + PermissionsPopupMenu popupMenu = new PermissionsPopupMenu(mContext, anchor, + R.menu.context_channel, this, this, mChannel, mService); + popupMenu.show(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChannelSearchProvider.java b/app/src/main/java/se/lublin/mumla/channel/ChannelSearchProvider.java new file mode 100644 index 00000000..a985fce0 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChannelSearchProvider.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.app.SearchManager; +import android.content.ComponentName; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Intent; +import android.content.ServiceConnection; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.IBinder; +import android.util.Log; + +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.mumla.R; +import se.lublin.mumla.service.MumlaService; + +public class ChannelSearchProvider extends ContentProvider { + private static final String TAG = ChannelSearchProvider.class.getName(); + + public static final String INTENT_DATA_CHANNEL = "channel"; + public static final String INTENT_DATA_USER = "user"; + + private IHumlaService mService; + private final Object mServiceLock = new Object(); + + private ServiceConnection mConn = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mService = ((MumlaService.MumlaBinder) service).getService(); + synchronized (mServiceLock) { + mServiceLock.notify(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + // TODO Auto-generated method stub + return 0; + } + + @Override + public String getType(Uri uri) { + // TODO Auto-generated method stub + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + // TODO Auto-generated method stub + return null; + } + + @Override + public boolean onCreate() { + return true; + } + + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + + // Try to connect to the service. Wait for conn to establish. + if(mService == null) { + Intent serviceIntent = new Intent(getContext(), MumlaService.class); + getContext().bindService(serviceIntent, mConn, 0); + + synchronized (mServiceLock) { + try { + mServiceLock.wait(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + if(mService == null) { + Log.v(TAG, "Failed to connect to service from search provider!"); + return null; + } + } + } + + if (!mService.isConnected()) + return null; + + IHumlaSession session = mService.HumlaSession(); + + String query = ""; + for(int x=0;x channels = channelSearch(session.getRootChannel(), query); + List users = userSearch(session.getRootChannel(), query); + + for(int x=0;x userSearch(IChannel root, String str) { + List list = new LinkedList(); + userSearch(root, str, list); + return list; + } + + /** + * @see #userSearch(IChannel,String) + */ + private void userSearch(IChannel root, String str, List users) { + if (root == null) { + return; + } + for (IUser user : root.getUsers()) { + if (user != null && user.getName() != null + && user.getName().toLowerCase().contains(str.toLowerCase())) { + users.add(user); + } + } + for (IChannel subc : root.getSubchannels()) { + if (subc != null) + userSearch(subc, str, users); + } + } + + /** + * Recursively searches the channel tree for a channel with a name containing the given string, + * ignoring case. + * @param root The channel to recursively search for subchannels within. + * @param str The string to match against the channel's name. Case insensitive. + * @return A list of channels whose names contain str. + */ + private List channelSearch(IChannel root, String str) { + List list = new LinkedList(); + channelSearch(root, str, list); + return list; + } + + /** + * @see #channelSearch(IChannel,String) + */ + private void channelSearch(IChannel root, String str, List channels) { + if (root == null) { + return; + } + + if (root.getName().toLowerCase().contains(str.toLowerCase())) { + channels.add(root); + } + + for (IChannel subc : root.getSubchannels()) { + if (subc != null) + channelSearch(subc, str, channels); + } + } + + @Override + public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + // TODO Auto-generated method stub + return 0; + } + +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChatTargetActionModeCallback.java b/app/src/main/java/se/lublin/mumla/channel/ChatTargetActionModeCallback.java new file mode 100644 index 00000000..d9b1726b --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChatTargetActionModeCallback.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.view.Menu; +import android.view.MenuItem; + +import androidx.appcompat.view.ActionMode; + +import se.lublin.mumla.R; + +/** + * A callback that sets the active chat target when activated, and resets when destroyed (usually + * to the user's current channel). + * Created by andrew on 26/06/14. + */ +public class ChatTargetActionModeCallback implements ActionMode.Callback { + private final ChatTargetProvider mProvider; + private final ChatTargetProvider.ChatTarget mChatTarget; + + public ChatTargetActionModeCallback(ChatTargetProvider provider, ChatTargetProvider.ChatTarget target) { + mProvider = provider; + mChatTarget = target; + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + actionMode.setTitle(mChatTarget.getName()); + actionMode.setSubtitle(R.string.current_chat_target); + mProvider.setChatTarget(getChatTarget()); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + mProvider.setChatTarget(null); + } + + public ChatTargetProvider.ChatTarget getChatTarget() { + return mChatTarget; + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ChatTargetProvider.java b/app/src/main/java/se/lublin/mumla/channel/ChatTargetProvider.java new file mode 100644 index 00000000..b27b251d --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ChatTargetProvider.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.mumla.BuildConfig; + +public interface ChatTargetProvider { + + /** + * Abstraction for user and channel chat targets. + */ + public class ChatTarget { + private IChannel mChannel; + private IUser mUser; + + public ChatTarget(IChannel channel) { + mChannel = channel; + } + + public ChatTarget(IUser user) { + mUser = user; + } + + public IChannel getChannel() { + return mChannel; + } + + public IUser getUser() { + return mUser; + } + + public String getName() { + if (mUser != null) + return mUser.getName(); + if (mChannel != null) + return mChannel.getName(); + if (BuildConfig.DEBUG) + throw new RuntimeException(); + return "Unknown"; + } + } + + /** + * Interface for classes which wish to receive chat target change calls. + * Created by andrew on 06/08/13. + */ + public interface OnChatTargetSelectedListener { + public void onChatTargetSelected(ChatTarget target); + } + + public ChatTarget getChatTarget(); + public void setChatTarget(ChatTarget target); + public void registerChatTargetListener(OnChatTargetSelectedListener listener); + public void unregisterChatTargetListener(OnChatTargetSelectedListener listener); +} \ No newline at end of file diff --git a/app/src/main/java/se/lublin/mumla/channel/OnChannelClickListener.java b/app/src/main/java/se/lublin/mumla/channel/OnChannelClickListener.java new file mode 100644 index 00000000..57a5cbce --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/OnChannelClickListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import se.lublin.humla.model.IChannel; + +/** + * Created by andrew on 20/10/14. + */ +public interface OnChannelClickListener { + public void onChannelClick(IChannel channel); +} diff --git a/app/src/main/java/se/lublin/mumla/channel/OnUserClickListener.java b/app/src/main/java/se/lublin/mumla/channel/OnUserClickListener.java new file mode 100644 index 00000000..580cf7cd --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/OnUserClickListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import se.lublin.humla.model.IUser; + +/** + * Created by andrew on 20/10/14. + */ +public interface OnUserClickListener { + public void onUserClick(IUser user); +} diff --git a/app/src/main/java/se/lublin/mumla/channel/PermissionsPopupMenu.java b/app/src/main/java/se/lublin/mumla/channel/PermissionsPopupMenu.java new file mode 100644 index 00000000..a8e9d3d0 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/PermissionsPopupMenu.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.Context; +import android.view.Menu; +import android.view.View; + +import androidx.appcompat.widget.PopupMenu; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.humla.util.IHumlaObserver; + +/** + * Encapsulates a menu requiring permissions. + * Created by andrew on 19/11/15. + */ +public class PermissionsPopupMenu implements PopupMenu.OnDismissListener { + private final Context mContext; + private final IChannel mChannel; + private final IHumlaService mService; + private final PopupMenu mMenu; + private final IOnMenuPrepareListener mPrepareListener; + + private final IHumlaObserver mPermissionsObserver = new HumlaObserver() { + @Override + public void onChannelPermissionsUpdated(IChannel channel) { + if (mChannel.equals(channel)) { + mPrepareListener.onMenuPrepare(mMenu.getMenu(), getPermissions()); + } + } + }; + + public PermissionsPopupMenu(Context context, View anchor, int menuRes, + IOnMenuPrepareListener enforcer, + PopupMenu.OnMenuItemClickListener itemClickListener, + IChannel channel, IHumlaService service) { + mContext = context; + mChannel = channel; + mService = service; + mPrepareListener = enforcer; + mMenu = new PopupMenu(mContext, anchor); + mMenu.inflate(menuRes); + mMenu.setOnDismissListener(this); + mMenu.setOnMenuItemClickListener(itemClickListener); + } + + private int getPermissions() { + if (mService.isConnected()) { + return mChannel.getId() == 0 ? mService.HumlaSession().getPermissions() + : mChannel.getPermissions(); + } + return 0; + } + + public void show() { + mService.registerObserver(mPermissionsObserver); + if (getPermissions() == 0) { + // onMenuPrepare will be called once more once permissions have loaded. + if (mService.isConnected()) { + mService.HumlaSession().requestPermissions(mChannel.getId()); + } + } else { + mPrepareListener.onMenuPrepare(mMenu.getMenu(), getPermissions()); + } + mMenu.show(); + } + + public void dismiss() { + mMenu.dismiss(); + } + + @Override + public void onDismiss(PopupMenu popupMenu) { + mService.unregisterObserver(mPermissionsObserver); + } + + /** + * An enforcer of channel permissions, disabling menu items as appropriate. + */ + public interface IOnMenuPrepareListener { + void onMenuPrepare(Menu menu, int permissions); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/ServerInfoFragment.java b/app/src/main/java/se/lublin/mumla/channel/ServerInfoFragment.java new file mode 100644 index 00000000..7e24a3b7 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/ServerInfoFragment.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.RemoteException; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.net.HumlaUDPMessageType; +import se.lublin.mumla.R; +import se.lublin.mumla.util.HumlaServiceFragment; + +/** + * A fragment that displays known information from the remote server. + * Created by andrew on 28/08/13. + */ +public class ServerInfoFragment extends HumlaServiceFragment { + + private static final int POLL_RATE = 1000; + + private ScheduledExecutorService mExecutorService = Executors.newSingleThreadScheduledExecutor(); + private Handler mHandler = new Handler(Looper.getMainLooper()); + private ScheduledFuture mPollFuture; + + private TextView mProtocolView; + private TextView mOSVersionView; + private TextView mTCPLatencyView; + private TextView mUDPLatencyView; + private TextView mHostView; + private TextView mCodecView; + private TextView mMaxBandwidthView; + private TextView mCurrentBandwidthView; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_server_info, container, false); + mProtocolView = (TextView) view.findViewById(R.id.server_info_protocol); + mOSVersionView = (TextView) view.findViewById(R.id.server_info_os_version); + mTCPLatencyView = (TextView) view.findViewById(R.id.server_info_tcp_latency); + mUDPLatencyView = (TextView) view.findViewById(R.id.server_info_udp_latency); + mHostView = (TextView) view.findViewById(R.id.server_info_host); + mMaxBandwidthView = (TextView) view.findViewById(R.id.server_info_max_bandwidth); + mCurrentBandwidthView = (TextView) view.findViewById(R.id.server_info_current_bandwidth); + mCodecView = (TextView) view.findViewById(R.id.server_info_codec); + return view; + } + + /** + * Updates the info from the service. + */ + public void updateData() throws RemoteException { + if(getService() == null || !getService().isConnected()) + return; + + IHumlaSession session = getService().HumlaSession(); + + mProtocolView.setText(getString(R.string.server_info_protocol, session.getServerRelease())); + mOSVersionView.setText(getString(R.string.server_info_version, session.getServerOSName(), session.getServerOSVersion())); + mTCPLatencyView.setText(getString(R.string.server_info_latency, (float)session.getTCPLatency()*Math.pow(10, -3))); + mUDPLatencyView.setText(getString(R.string.server_info_latency, (float)session.getUDPLatency()*Math.pow(10, -3))); + // TODO SRV note also getHost,Port? + mHostView.setText(getString(R.string.server_info_host, + getService().getTargetServer().getSrvHost(), + getService().getTargetServer().getSrvPort())); + + String codecName = ""; + HumlaUDPMessageType codecType = session.getCodec(); + if (codecType != null) { + switch (codecType) { + case UDPVoiceOpus: + codecName = "Opus"; + break; + case UDPVoiceCELTBeta: + codecName = "CELT 0.11.0"; + break; + case UDPVoiceCELTAlpha: + codecName = "CELT 0.7.0"; + break; + case UDPVoiceSpeex: + codecName = "Speex"; + break; + default: + codecName = "???"; + } + } + + mMaxBandwidthView.setText(getString(R.string.server_info_max_bandwidth, (float)session.getMaxBandwidth()/1000f)); + mCurrentBandwidthView.setText(getString(R.string.server_info_current_bandwidth, (float)session.getCurrentBandwidth()/1000f)); + mCodecView.setText(getString(R.string.server_info_codec, codecName)); + } + + @Override + public void onServiceBound(IHumlaService service) { + // wow this is ugly + mPollFuture = mExecutorService.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + mHandler.post(new Runnable() { + @Override + public void run() { + try { + if(isVisible()) updateData(); + } catch (RemoteException e) { + e.printStackTrace(); + } + } + }); + } + }, 0, POLL_RATE, TimeUnit.MILLISECONDS); + } + + @Override + public void onServiceUnbound() { + if (mPollFuture != null) { + mPollFuture.cancel(true); + mPollFuture = null; + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mExecutorService.shutdownNow(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/UserMenu.java b/app/src/main/java/se/lublin/mumla/channel/UserMenu.java new file mode 100644 index 00000000..b3f80966 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/UserMenu.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2015 Andrew Comminos + * + * 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 se.lublin.mumla.channel; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.EditText; + +import androidx.appcompat.widget.PopupMenu; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.List; + +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.net.Permissions; +import se.lublin.mumla.R; +import se.lublin.mumla.channel.comment.UserCommentFragment; +import se.lublin.mumla.service.MumlaService; +import se.lublin.mumla.util.ModelUtils; + +/** + * Created by andrew on 19/11/15. + */ +public class UserMenu implements PermissionsPopupMenu.IOnMenuPrepareListener, PopupMenu.OnMenuItemClickListener { + private static final String TAG = UserMenu.class.getName(); + + private final Context mContext; + private final IUser mUser; + private final MumlaService mService; + private final FragmentManager mFragmentManager; + private final IUserLocalStateListener mStateListener; + + public UserMenu(Context context, IUser user, MumlaService service, + FragmentManager fragmentManager, IUserLocalStateListener stateListener) { + mContext = context; + mUser = user; + mService = service; + mFragmentManager = fragmentManager; + mStateListener = stateListener; + } + + @Override + public void onMenuPrepare(Menu menu, int permissions) { + // Use permission data to determine the actions available. + boolean self; + try { + self = mUser.getSession() == mService.getSessionId(); + } catch (IllegalStateException e) { + Log.d(TAG, "exception in onMenuPrepare: " + e); + return; + } + int perms = mService.getPermissions(); + IChannel channel = mUser.getChannel(); + if (channel == null) { + Log.d(TAG, "mUser.getChannel()==null in onMenuPrepare"); + return; + } + int channelPerms = channel.getId() != 0 ? channel.getPermissions() : perms; + + menu.findItem(R.id.context_kick).setVisible( + !self && (perms & (Permissions.Kick | Permissions.Ban | Permissions.Write)) > 0); + menu.findItem(R.id.context_ban).setVisible( + !self && (perms & (Permissions.Ban | Permissions.Write)) > 0); + menu.findItem(R.id.context_mute).setVisible( + ((channelPerms & (Permissions.Write | Permissions.MuteDeafen)) > 0 && + (!self || mUser.isMuted() || mUser.isSuppressed()))); + menu.findItem(R.id.context_deafen).setVisible( + ((channelPerms & (Permissions.Write | Permissions.MuteDeafen)) > 0 && + (!self || mUser.isDeafened()))); + menu.findItem(R.id.context_priority).setVisible( + ((channelPerms & (Permissions.Write | Permissions.MuteDeafen)) > 0)); + menu.findItem(R.id.context_move).setVisible( + !self && (perms & Permissions.Move) > 0); + menu.findItem(R.id.context_change_comment).setVisible(self); + menu.findItem(R.id.context_reset_comment).setVisible( + !self && ((mUser.getComment() != null && !mUser.getComment().isEmpty()) || + (mUser.getCommentHash() != null)) && + (perms & (Permissions.Move | Permissions.Write)) > 0); + menu.findItem(R.id.context_view_comment).setVisible( + (mUser.getComment() != null && !mUser.getComment().isEmpty()) || + (mUser.getCommentHash() != null)); + menu.findItem(R.id.context_register).setVisible(mUser.getUserId() < 0 && + (mUser.getHash() != null && !mUser.getHash().isEmpty()) && + (perms & ((self ? Permissions.SelfRegister : Permissions.Register) | Permissions.Write)) > 0); + menu.findItem(R.id.context_local_mute).setVisible(!self); + menu.findItem(R.id.context_ignore_messages).setVisible(!self); + + // TODO info +// informationItem.enabled = (((perms & (Permissions.Write | Permissions.Register))) > 0 || (channelPermissions & (Permissions.Write | Permissions.Enter)) > 0 || (mUser.getSessionId() == mService.getSessionId())); + + // Highlight toggles + menu.findItem(R.id.context_mute).setChecked(mUser.isMuted() || mUser.isSuppressed()); + menu.findItem(R.id.context_deafen).setChecked(mUser.isDeafened()); + menu.findItem(R.id.context_priority).setChecked(mUser.isPrioritySpeaker()); + menu.findItem(R.id.context_local_mute).setChecked(mUser.isLocalMuted()); + menu.findItem(R.id.context_ignore_messages).setChecked(mUser.isLocalIgnored()); + } + + @Override + public boolean onMenuItemClick(final MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.context_ban || itemId == R.id.context_kick) { + final EditText reasonField = new EditText(mContext); + reasonField.setHint(R.string.hint_reason); + new MaterialAlertDialogBuilder(mContext) + .setTitle(R.string.user_menu_kick) + .setView(reasonField) + .setPositiveButton(R.string.user_menu_kick, (dialog, which) -> + mService.kickBanUser(mUser.getSession(), reasonField.getText().toString(), menuItem.getItemId() == R.id.context_ban)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else if (itemId == R.id.context_mute) { + mService.setMuteDeafState(mUser.getSession(), !(mUser.isMuted() || mUser.isSuppressed()), mUser.isDeafened()); + } else if (itemId == R.id.context_deafen) { + mService.setMuteDeafState(mUser.getSession(), mUser.isMuted(), !mUser.isDeafened()); + } else if (itemId == R.id.context_move) { + showChannelMoveDialog(); + } else if (itemId == R.id.context_priority) { + mService.setPrioritySpeaker(mUser.getSession(), !mUser.isPrioritySpeaker()); + } else if (itemId == R.id.context_local_mute) { + mUser.setLocalMuted(!mUser.isLocalMuted()); + mStateListener.onLocalUserStateUpdated(mUser); + } else if (itemId == R.id.context_ignore_messages) { + mUser.setLocalIgnored(!mUser.isLocalIgnored()); + mStateListener.onLocalUserStateUpdated(mUser); + } else if (itemId == R.id.context_change_comment) { + showUserComment(true); + } else if (itemId == R.id.context_view_comment) { + showUserComment(false); + } else if (itemId == R.id.context_reset_comment) { + new MaterialAlertDialogBuilder(mContext) + .setMessage(mContext.getString(R.string.confirm_reset_comment, mUser.getName())) + .setPositiveButton(R.string.confirm, (dialog, which) -> + mService.setUserComment(mUser.getSession(), "")) + .setNegativeButton(android.R.string.cancel, null) + .show(); +// } else if (itemId == R.id.context_info) { + } else if (itemId == R.id.context_register) { + mService.registerUser(mUser.getSession()); + } else { + return false; + } + return true; + } + + private void showUserComment(final boolean edit) { + Bundle args = new Bundle(); + args.putInt("session", mUser.getSession()); + args.putString("comment", mUser.getComment()); + args.putBoolean("editing", edit); + UserCommentFragment fragment = (UserCommentFragment) Fragment.instantiate(mContext, UserCommentFragment.class.getName(), args); + fragment.show(mFragmentManager, UserCommentFragment.class.getName()); + } + + private void showChannelMoveDialog() { + final List channels = ModelUtils.getChannelList(mService.getRootChannel()); + final CharSequence[] channelNames = new CharSequence[channels.size()]; + for (int i = 0; i < channels.size(); i++) { + channelNames[i] = channels.get(i).getName(); + } + new MaterialAlertDialogBuilder(mContext) + .setTitle(R.string.user_menu_move) + .setItems(channelNames, (dialog, which) -> { + IChannel channel = channels.get(which); + mService.moveUserToChannel(mUser.getSession(), channel.getId()); + }) + .show(); + } + + public void showPopup(View anchor) { + PermissionsPopupMenu popupMenu = new PermissionsPopupMenu(mContext, anchor, + R.menu.context_user, this, this, mUser.getChannel(), mService); + popupMenu.show(); + } + + /** + * A listener notified whenever the user's local state changes. + */ + public interface IUserLocalStateListener { + void onLocalUserStateUpdated(IUser user); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/comment/AbstractCommentFragment.java b/app/src/main/java/se/lublin/mumla/channel/comment/AbstractCommentFragment.java new file mode 100644 index 00000000..90edfd70 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/comment/AbstractCommentFragment.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel.comment; + +import android.app.Activity; +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.webkit.WebView; +import android.widget.EditText; +import android.widget.TabHost; + +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import se.lublin.humla.IHumlaService; +import se.lublin.mumla.R; +import se.lublin.mumla.util.HumlaServiceProvider; + +/** + * Fragment to change your comment using basic WYSIWYG tools. + * Created by andrew on 10/08/13. + */ +public abstract class AbstractCommentFragment extends DialogFragment { + + private TabHost mTabHost; + private WebView mCommentView; + private EditText mCommentEdit; + private HumlaServiceProvider mProvider; + private String mComment; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mComment = getArguments().getString("comment"); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mProvider = (HumlaServiceProvider) activity; + } catch (ClassCastException e) { + throw new RuntimeException(activity.getClass().getName() + " must implement HumlaServiceProvider!"); + } + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + LayoutInflater inflater = LayoutInflater.from(getActivity()); + View view = inflater.inflate(R.layout.dialog_comment, null, false); + + mCommentView = (WebView) view.findViewById(R.id.comment_view); + mCommentEdit = (EditText) view.findViewById(R.id.comment_edit); + + mTabHost = (TabHost) view.findViewById(R.id.comment_tabhost); + mTabHost.setup(); + + if (mComment == null) { + mCommentView.loadData("Loading...", null, null); + requestComment(mProvider.getService()); + } else { + loadComment(mComment); + } + + TabHost.TabSpec viewTab = mTabHost.newTabSpec("View"); + viewTab.setIndicator(getString(R.string.comment_view)); + viewTab.setContent(R.id.comment_tab_view); + + TabHost.TabSpec editTab = mTabHost.newTabSpec("Edit"); + editTab.setIndicator(getString(isEditing() ? R.string.comment_edit_source : R.string.comment_view_source)); + editTab.setContent(R.id.comment_tab_edit); + + mTabHost.addTab(viewTab); + mTabHost.addTab(editTab); + + mTabHost.setOnTabChangedListener(new TabHost.OnTabChangeListener() { + @Override + public void onTabChanged(String tabId) { + if ("View".equals(tabId)) { + // When switching back to view tab, update with user's HTML changes. + mCommentView.loadData(mCommentEdit.getText().toString(), "text/html", "UTF-8"); + } else if ("Edit".equals(tabId) && "".equals(mCommentEdit.getText().toString())) { + // Load edittext content for the first time when the tab is selected, to improve performance with long messages. + mCommentEdit.setText(mComment); + } + } + }); + + mTabHost.setCurrentTab(isEditing() ? 1 : 0); + + if (isEditing()) { + return new MaterialAlertDialogBuilder(requireActivity()) + .setView(view) + .setNegativeButton(R.string.close, null) + .setPositiveButton(R.string.save, (dialog, which) -> + editComment(mProvider.getService(), mCommentEdit.getText().toString())) + .create(); + } else { + return new MaterialAlertDialogBuilder(requireActivity()) + .setView(view) + .setNegativeButton(R.string.close, null) + .create(); + } + } + + protected void loadComment(String comment) { + if(mCommentView == null) return; + mCommentView.loadData(comment, "text/html", "UTF-8"); + mComment = comment; + } + + public boolean isEditing() { + return getArguments().getBoolean("editing"); + } + + /** + * Requests a comment from the service. Will not be called if we already have a comment provided. + * This method is expected to set a callback that will call {@link se.lublin.mumla.channel.comment.AbstractCommentFragment#loadComment(String comment)}. + * @param service The bound Humla service to use for remote calls. + */ + public abstract void requestComment(IHumlaService service); + + /** + * Asks the service to replace the comment. + * @param service The bound Humla service to use for remote calls. + * @param comment The comment the user has defined. + */ + public abstract void editComment(IHumlaService service, String comment); +} diff --git a/app/src/main/java/se/lublin/mumla/channel/comment/ChannelDescriptionFragment.java b/app/src/main/java/se/lublin/mumla/channel/comment/ChannelDescriptionFragment.java new file mode 100644 index 00000000..3cd6c695 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/comment/ChannelDescriptionFragment.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel.comment; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.util.HumlaObserver; + +/** + * Created by andrew on 03/03/14. + */ +public class ChannelDescriptionFragment extends AbstractCommentFragment { + + @Override + public void requestComment(final IHumlaService service) { + if (!service.isConnected()) + return; + service.registerObserver(new HumlaObserver() { + @Override + public void onChannelStateUpdated(IChannel channel) { + if(channel.getId() == getChannelId() && + channel.getDescription() != null) { + loadComment(channel.getDescription()); + service.unregisterObserver(this); + } + } + }); + service.HumlaSession().requestChannelDescription(getChannelId()); + } + + @Override + public void editComment(IHumlaService service, String comment) { + // TODO + } + + private int getChannelId() { + return getArguments().getInt("channel"); + } +} diff --git a/app/src/main/java/se/lublin/mumla/channel/comment/UserCommentFragment.java b/app/src/main/java/se/lublin/mumla/channel/comment/UserCommentFragment.java new file mode 100644 index 00000000..f007ad9e --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/channel/comment/UserCommentFragment.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.channel.comment; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.model.IUser; +import se.lublin.humla.util.HumlaObserver; + +/** + * Created by andrew on 03/03/14. + */ +public class UserCommentFragment extends AbstractCommentFragment { + + @Override + public void requestComment(final IHumlaService service) { + if (!service.isConnected()) + return; + service.registerObserver(new HumlaObserver() { + @Override + public void onUserStateUpdated(IUser user) { + if(user.getSession() == getSession() && + user.getComment() != null) { + loadComment(user.getComment()); + service.unregisterObserver(this); + } + } + }); + service.HumlaSession().requestComment(getSession()); + } + + @Override + public void editComment(IHumlaService service, String comment) { + if (!service.isConnected()) + return; + service.HumlaSession().setUserComment(getSession(), comment); + } + + public int getSession() { + return getArguments().getInt("session"); + } +} diff --git a/app/src/main/java/se/lublin/mumla/db/DatabaseCertificate.java b/app/src/main/java/se/lublin/mumla/db/DatabaseCertificate.java new file mode 100644 index 00000000..f61654f1 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/db/DatabaseCertificate.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.db; + +/** + * A stub for a certificate entry in Mumla's database. + * Created by andrew on 11/01/16. + */ +public class DatabaseCertificate { + private final long mId; + private final String mName; + + protected DatabaseCertificate(long id, String name) { + mId = id; + mName = name; + } + + public long getId() { + return mId; + } + + public String getName() { + return mName; + } + + @Override + public String toString() { + return mName; + } +} diff --git a/app/src/main/java/se/lublin/mumla/db/DatabaseProvider.java b/app/src/main/java/se/lublin/mumla/db/DatabaseProvider.java new file mode 100644 index 00000000..f78ce307 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/db/DatabaseProvider.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.db; + +/** + * Created by andrew on 15/08/13. + */ +public interface DatabaseProvider { + public MumlaDatabase getDatabase(); +} diff --git a/app/src/main/java/se/lublin/mumla/db/MumlaDatabase.java b/app/src/main/java/se/lublin/mumla/db/MumlaDatabase.java new file mode 100644 index 00000000..37e9fbed --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/db/MumlaDatabase.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.db; + +import java.util.List; + +import se.lublin.humla.model.Server; + +/** + * An interface for persistent storage services (i.e. databases, cloud) to implement. + * Created by andrew on 13/08/13. + */ +public interface MumlaDatabase { + public void open(); + public void close(); + + public List getServers(); + public void addServer(Server server); + public void updateServer(Server server); + public void removeServer(Server server); + + public boolean isCommentSeen(String hash, byte[] commentHash); + public void markCommentSeen(String hash, byte[] commentHash); + + public List getPinnedChannels(long serverId); + public void addPinnedChannel(long serverId, int channelId); + public void removePinnedChannel(long serverId, int channelId); + public boolean isChannelPinned(long serverId, int channelId); + + public List getAccessTokens(long serverId); + public void addAccessToken(long serverId, String token); + public void removeAccessToken(long serverId, String token); + + public List getLocalMutedUsers(long serverId); + public void addLocalMutedUser(long serverId, int userId); + public void removeLocalMutedUser(long serverId, int userId); + + public List getLocalIgnoredUsers(long serverId); + public void addLocalIgnoredUser(long serverId, int userId); + public void removeLocalIgnoredUser(long serverId, int userId); + + /** + * Adds the given certificate binary blob to the database. + * @param name The user-readable certificate name. + * @param certificate A PKCS12 binary blob. + * @return A handle for the newly craeted certificate. + */ + DatabaseCertificate addCertificate(String name, byte[] certificate); + List getCertificates(); + + /** + * Obtains the certificate data associated with the given certificate ID. + * @param id The certificate ID to fetch the data of. + * @return A binary representation of a PKCS12 certificate. + */ + byte[] getCertificateData(long id); + + /** + * Removes the certificate with the given ID. + * @param id The certificate's identifier. + */ + void removeCertificate(long id); +} diff --git a/app/src/main/java/se/lublin/mumla/db/MumlaSQLiteDatabase.java b/app/src/main/java/se/lublin/mumla/db/MumlaSQLiteDatabase.java new file mode 100644 index 00000000..4826333e --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/db/MumlaSQLiteDatabase.java @@ -0,0 +1,448 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.db; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.humla.model.Server; + +public class MumlaSQLiteDatabase extends SQLiteOpenHelper implements MumlaDatabase { + private static final String TAG = MumlaSQLiteDatabase.class.getName(); + + public static final String DATABASE_NAME = "mumble.db"; + + public static final String TABLE_SERVER = "server"; + public static final String SERVER_ID = "_id"; + public static final String SERVER_NAME = "name"; + public static final String SERVER_HOST = "host"; + public static final String SERVER_PORT = "port"; + public static final String SERVER_USERNAME = "username"; + public static final String SERVER_PASSWORD = "password"; + public static final String TABLE_SERVER_CREATE_SQL = "CREATE TABLE IF NOT EXISTS `" + TABLE_SERVER + "` (" + + "`" + SERVER_ID + "` INTEGER PRIMARY KEY AUTOINCREMENT," + + "`" + SERVER_NAME + "` TEXT NOT NULL," + + "`" + SERVER_HOST + "` TEXT NOT NULL," + + "`" + SERVER_PORT + "` INTEGER," + + "`" + SERVER_USERNAME + "` TEXT NOT NULL," + + "`" + SERVER_PASSWORD + "` TEXT" + + ");"; + + public static final String TABLE_FAVOURITES = "favourites"; + public static final String FAVOURITES_ID = "_id"; + public static final String FAVOURITES_CHANNEL = "channel"; + public static final String FAVOURITES_SERVER = "server"; + public static final String TABLE_FAVOURITES_CREATE_SQL = "CREATE TABLE IF NOT EXISTS `" + TABLE_FAVOURITES + "` (" + + "`" + FAVOURITES_ID + "` INTEGER PRIMARY KEY AUTOINCREMENT," + + "`" + FAVOURITES_CHANNEL + "` TEXT NOT NULL," + + "`" + FAVOURITES_SERVER + "` INTEGER NOT NULL" + + ");"; + + public static final String TABLE_TOKENS = "tokens"; + public static final String TOKENS_ID = "_id"; + public static final String TOKENS_VALUE = "value"; + public static final String TOKENS_SERVER = "server"; + public static final String TABLE_TOKENS_CREATE_SQL = "CREATE TABLE IF NOT EXISTS `" + TABLE_TOKENS + "` (" + + "`" + TOKENS_ID + "` INTEGER PRIMARY KEY AUTOINCREMENT," + + "`" + TOKENS_VALUE + "` TEXT NOT NULL," + + "`" + TOKENS_SERVER + "` INTEGER NOT NULL" + + ");"; + + public static final String TABLE_COMMENTS = "comments"; + public static final String COMMENTS_WHO = "who"; + public static final String COMMENTS_COMMENT = "comment"; + public static final String COMMENTS_SEEN = "seen"; + public static final String TABLE_COMMENTS_CREATE_SQL = "CREATE TABLE IF NOT EXISTS `" + TABLE_COMMENTS + "` (" + + "`" + COMMENTS_WHO + "` TEXT NOT NULL," + + "`" + COMMENTS_COMMENT + "` TEXT NOT NULL," + + "`" + COMMENTS_SEEN + "` DATE NOT NULL" + + ");"; + + public static final String TABLE_LOCAL_MUTE = "local_mute"; + public static final String LOCAL_MUTE_SERVER = "server"; + public static final String LOCAL_MUTE_USER = "user"; + public static final String TABLE_LOCAL_MUTE_CREATE_SQL = "CREATE TABLE IF NOT EXISTS " + TABLE_LOCAL_MUTE + " (" + + "`" + LOCAL_MUTE_SERVER +"` INTEGER NOT NULL," + + "`" + LOCAL_MUTE_USER + "` INTEGER NOT NULL," + + "CONSTRAINT server_user UNIQUE(" + LOCAL_MUTE_SERVER + "," + LOCAL_MUTE_USER + ")" + + ");"; + + public static final String TABLE_LOCAL_IGNORE = "local_ignore"; + public static final String LOCAL_IGNORE_SERVER = "server"; + public static final String LOCAL_IGNORE_USER = "user"; + public static final String TABLE_LOCAL_IGNORE_CREATE_SQL = "CREATE TABLE IF NOT EXISTS " + TABLE_LOCAL_IGNORE + " (" + + "`" + LOCAL_IGNORE_SERVER +"` INTEGER NOT NULL," + + "`" + LOCAL_IGNORE_USER + "` INTEGER NOT NULL," + + "CONSTRAINT server_user UNIQUE(" + LOCAL_IGNORE_SERVER + "," + LOCAL_IGNORE_USER + ")" + + ");"; + + public static final String TABLE_CERTIFICATES = "certificates"; + public static final String COLUMN_CERTIFICATES_ID = "_id"; + public static final String COLUMN_CERTIFICATES_DATA = "data"; + public static final String COLUMN_CERTIFICATES_NAME = "name"; + public static final String TABLE_CERTIFICATES_CREATE_SQL = "CREATE TABLE IF NOT EXISTS " + TABLE_CERTIFICATES + " (" + + "`" + COLUMN_CERTIFICATES_ID + "` INTEGER PRIMARY KEY AUTOINCREMENT," + + "`" + COLUMN_CERTIFICATES_DATA + "` BLOB NOT NULL," + + "`" + COLUMN_CERTIFICATES_NAME + "` TEXT NOT NULL" + + ");"; + + public static final Integer PRE_FAVOURITES_DB_VERSION = 2; + public static final Integer PRE_TOKENS_DB_VERSION = 3; + public static final Integer PRE_COMMENTS_DB_VERSION = 4; + public static final Integer PRE_LOCAL_MUTE_DB_VERSION = 5; + public static final Integer PRE_LOCAL_IGNORE_DB_VERSION = 6; + public static final Integer PRE_CERTIFICATES_DB_VERSION = 7; + public static final Integer CURRENT_DB_VERSION = 8; + + public MumlaSQLiteDatabase(Context context) { + super(context, DATABASE_NAME, null, CURRENT_DB_VERSION); + } + + public MumlaSQLiteDatabase(Context context, String name) { + super(context, name, null, CURRENT_DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(TABLE_SERVER_CREATE_SQL); + db.execSQL(TABLE_FAVOURITES_CREATE_SQL); + db.execSQL(TABLE_TOKENS_CREATE_SQL); + db.execSQL(TABLE_COMMENTS_CREATE_SQL); + db.execSQL(TABLE_LOCAL_MUTE_CREATE_SQL); + db.execSQL(TABLE_LOCAL_IGNORE_CREATE_SQL); + db.execSQL(TABLE_CERTIFICATES_CREATE_SQL); + } + + @Override + public void onUpgrade( + SQLiteDatabase db, + int oldVersion, + int newVersion) { + Log.w(TAG, "Database upgrade from " + oldVersion + " to " + newVersion); + if (oldVersion <= PRE_FAVOURITES_DB_VERSION) { + db.execSQL(TABLE_FAVOURITES_CREATE_SQL); + } + + if (oldVersion <= PRE_TOKENS_DB_VERSION) { + db.execSQL(TABLE_TOKENS_CREATE_SQL); + } + + if (oldVersion <= PRE_COMMENTS_DB_VERSION) { + db.execSQL(TABLE_COMMENTS_CREATE_SQL); + } + + if (oldVersion <= PRE_LOCAL_MUTE_DB_VERSION) { + db.execSQL(TABLE_LOCAL_MUTE_CREATE_SQL); + } + + if (oldVersion <= PRE_LOCAL_IGNORE_DB_VERSION) { + db.execSQL(TABLE_LOCAL_IGNORE_CREATE_SQL); + } + + if (oldVersion <= PRE_CERTIFICATES_DB_VERSION) { + db.execSQL(TABLE_CERTIFICATES_CREATE_SQL); + } + } + + @Override + public void open() { + // Do nothing. Database will be opened automatically when accessing it. + } + + @Override + public List getServers() { + Cursor c = getReadableDatabase().query( + TABLE_SERVER, + new String[]{SERVER_ID, SERVER_NAME, SERVER_HOST, + SERVER_PORT, SERVER_USERNAME, SERVER_PASSWORD}, + null, + null, + null, + null, + null); + + List servers = new ArrayList(); + + c.moveToFirst(); + while (!c.isAfterLast()) { + Server server = new Server(c.getInt(c.getColumnIndex(SERVER_ID)), + c.getString(c.getColumnIndex(SERVER_NAME)), + c.getString(c.getColumnIndex(SERVER_HOST)), + c.getInt(c.getColumnIndex(SERVER_PORT)), + c.getString(c.getColumnIndex(SERVER_USERNAME)), + c.getString(c.getColumnIndex(SERVER_PASSWORD))); + servers.add(server); + c.moveToNext(); + } + + c.close(); + + return servers; + } + + @Override + public void addServer(Server server) { + ContentValues values = new ContentValues(); + values.put(SERVER_NAME, server.getName()); + values.put(SERVER_HOST, server.getHost()); + values.put(SERVER_PORT, server.getPort()); + values.put(SERVER_USERNAME, server.getUsername()); + values.put(SERVER_PASSWORD, server.getPassword()); + + server.setId(getWritableDatabase().insert(TABLE_SERVER, null, values)); + } + + @Override + public void updateServer(Server server) { + ContentValues values = new ContentValues(); + values.put(SERVER_NAME, server.getName()); + values.put(SERVER_HOST, server.getHost()); + values.put(SERVER_PORT, server.getPort()); + values.put(SERVER_USERNAME, server.getUsername()); + values.put(SERVER_PASSWORD, server.getPassword()); + getWritableDatabase().update( + TABLE_SERVER, + values, + SERVER_ID + "=?", + new String[]{Long.toString(server.getId())}); + } + + @Override + public void removeServer(Server server) { + getWritableDatabase().delete(TABLE_SERVER, SERVER_ID + "=?", + new String[] { String.valueOf(server.getId()) }); + // Clean up server-specific entries + getWritableDatabase().delete(TABLE_FAVOURITES, FAVOURITES_SERVER + "=?", + new String[] { String.valueOf(server.getId()) }); + getWritableDatabase().delete(TABLE_TOKENS, TOKENS_SERVER + "=?", + new String[] { String.valueOf(server.getId()) }); + getWritableDatabase().delete(TABLE_LOCAL_MUTE, LOCAL_MUTE_SERVER + "=?", + new String[] { String.valueOf(server.getId()) }); + getWritableDatabase().delete(TABLE_LOCAL_IGNORE, LOCAL_IGNORE_SERVER + "=?", + new String[]{String.valueOf(server.getId())}); + } + + public List getPinnedChannels(long serverId) { + + final Cursor c = getReadableDatabase().query( + TABLE_FAVOURITES, + new String[]{FAVOURITES_CHANNEL}, + FAVOURITES_SERVER + "=?", + new String[]{String.valueOf(serverId)}, + null, + null, + null); + + List favourites = new ArrayList(); + c.moveToFirst(); + while (!c.isAfterLast()) { + favourites.add(c.getInt(0)); + c.moveToNext(); + } + + c.close(); + + return favourites; + } + + @Override + public void addPinnedChannel(long serverId, int channelId) { + ContentValues contentValues = new ContentValues(); + contentValues.put(FAVOURITES_CHANNEL, channelId); + contentValues.put(FAVOURITES_SERVER, serverId); + getWritableDatabase().insert(TABLE_FAVOURITES, null, contentValues); + } + + @Override + public boolean isChannelPinned(long serverId, int channelId) { + Cursor c = getReadableDatabase().query( + TABLE_FAVOURITES, + new String[]{FAVOURITES_CHANNEL}, + FAVOURITES_SERVER + "=? AND " + + FAVOURITES_CHANNEL + "=?", + new String[]{String.valueOf(serverId), String.valueOf(channelId)}, + null, + null, + null); + c.moveToFirst(); + return !c.isAfterLast(); + } + + public void removePinnedChannel(long serverId, int channelId) { + getWritableDatabase().delete(TABLE_FAVOURITES, "server = ? AND channel = ?", new String[] { Long.toString(serverId), Integer.toString(channelId)}); + } + + @Override + public List getAccessTokens(long serverId) { + Cursor cursor = getReadableDatabase().query(TABLE_TOKENS, new String[] { TOKENS_VALUE }, TOKENS_SERVER+"=?", new String[] { String.valueOf(serverId) }, null, null, null); + cursor.moveToFirst(); + List tokens = new ArrayList(); + while(!cursor.isAfterLast()) { + tokens.add(cursor.getString(0)); + cursor.moveToNext(); + } + cursor.close(); + return tokens; + } + + @Override + public void addAccessToken(long serverId, String token) { + ContentValues contentValues = new ContentValues(); + contentValues.put(TOKENS_SERVER, serverId); + contentValues.put(TOKENS_VALUE, token); + getWritableDatabase().insert(TABLE_TOKENS, null, contentValues); + } + + @Override + public void removeAccessToken(long serverId, String token) { + getWritableDatabase().delete(TABLE_TOKENS, TOKENS_SERVER+"=? AND "+TOKENS_VALUE+"=?", new String[] {String.valueOf(serverId), token }); + } + + @Override + public List getLocalMutedUsers(long serverId) { + Cursor cursor = getReadableDatabase().query(TABLE_LOCAL_MUTE, + new String[] { LOCAL_MUTE_USER }, + LOCAL_MUTE_SERVER + "=?", + new String[] { String.valueOf(serverId) }, + null, null, null); + cursor.moveToNext(); + List users = new ArrayList(); + while (!cursor.isAfterLast()) { + users.add(cursor.getInt(0)); + cursor.moveToNext(); + } + return users; + } + + @Override + public void addLocalMutedUser(long serverId, int userId) { + ContentValues values = new ContentValues(); + values.put(LOCAL_MUTE_SERVER, serverId); + values.put(LOCAL_MUTE_USER, userId); + getWritableDatabase().insert(TABLE_LOCAL_MUTE, null, values); + } + + @Override + public void removeLocalMutedUser(long serverId, int userId) { + getWritableDatabase().delete(TABLE_LOCAL_MUTE, + LOCAL_MUTE_SERVER + "=? AND " + LOCAL_MUTE_USER + "=?", + new String[] { String.valueOf(serverId), String.valueOf(userId) }); + } + + @Override + public List getLocalIgnoredUsers(long serverId) { + Cursor cursor = getReadableDatabase().query(TABLE_LOCAL_IGNORE, + new String[] { LOCAL_IGNORE_USER }, + LOCAL_IGNORE_SERVER + "=?", + new String[] { String.valueOf(serverId) }, + null, null, null); + cursor.moveToFirst(); + List users = new ArrayList(); + while (!cursor.isAfterLast()) { + users.add(cursor.getInt(0)); + cursor.moveToNext(); + } + return users; + } + + @Override + public void addLocalIgnoredUser(long serverId, int userId) { + ContentValues values = new ContentValues(); + values.put(LOCAL_IGNORE_SERVER, serverId); + values.put(LOCAL_IGNORE_USER, userId); + getWritableDatabase().insert(TABLE_LOCAL_IGNORE, null, values); + } + + @Override + public void removeLocalIgnoredUser(long serverId, int userId) { + getWritableDatabase().delete(TABLE_LOCAL_IGNORE, + LOCAL_IGNORE_SERVER + "=? AND " + LOCAL_IGNORE_USER + "=?", + new String[] { String.valueOf(serverId), String.valueOf(userId) }); + } + + @Override + public DatabaseCertificate addCertificate(String name, byte[] certificate) { + ContentValues values = new ContentValues(); + values.put(COLUMN_CERTIFICATES_NAME, name); + values.put(COLUMN_CERTIFICATES_DATA, certificate); + long id = getWritableDatabase().insert(TABLE_CERTIFICATES, null, values); + return new DatabaseCertificate(id, name); + } + + @Override + public List getCertificates() { + Cursor cursor = getReadableDatabase().query(TABLE_CERTIFICATES, + new String[] { COLUMN_CERTIFICATES_ID, COLUMN_CERTIFICATES_NAME }, + null, null, null, null, null); + List certificates = new ArrayList<>(); + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + certificates.add(new DatabaseCertificate(cursor.getLong(0), cursor.getString(1))); + cursor.moveToNext(); + } + cursor.close(); + return certificates; + } + + @Override + public byte[] getCertificateData(long id) { + Cursor cursor = getReadableDatabase().query(TABLE_CERTIFICATES, + new String[] { COLUMN_CERTIFICATES_DATA }, + COLUMN_CERTIFICATES_ID + "=?", + new String[] { String.valueOf(id) }, null, null, null); + if (!cursor.moveToFirst()) + return null; + byte[] data = cursor.getBlob(0); + cursor.close(); + return data; + } + + @Override + public void removeCertificate(long id) { + getWritableDatabase().delete(TABLE_CERTIFICATES, + COLUMN_CERTIFICATES_ID + "=?", + new String[] { String.valueOf(id) }); + } + + @Override + public boolean isCommentSeen(String hash, byte[] commentHash) { + Cursor cursor = getReadableDatabase().query(TABLE_COMMENTS, + new String[]{COMMENTS_WHO, COMMENTS_COMMENT, COMMENTS_SEEN}, COMMENTS_WHO + "=? AND " + COMMENTS_COMMENT + "=?", + new String[]{hash, new String(commentHash)}, + null, null, null); + boolean hasNext = cursor.moveToNext(); + cursor.close(); + return hasNext; + } + + @Override + public void markCommentSeen(String hash, byte[] commentHash) { + ContentValues values = new ContentValues(); + values.put(COMMENTS_WHO, hash); + values.put(COMMENTS_COMMENT, commentHash); + values.put(COMMENTS_SEEN, "datetime('now')"); + getWritableDatabase().replace(TABLE_COMMENTS, null, values); + } +} diff --git a/app/src/main/java/se/lublin/mumla/db/PublicServer.java b/app/src/main/java/se/lublin/mumla/db/PublicServer.java new file mode 100644 index 00000000..725edd02 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/db/PublicServer.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.db; + + +import se.lublin.humla.model.Server; + +public class PublicServer extends Server { + + private String mCA; + private String mContinentCode; + private String mCountry; + private String mCountryCode; + private String mRegion; + private String mUrl; + + public PublicServer(String name, String ca, String continentCode, String country, String countryCode, String ip, Integer port, String region, String url) { + super(-1, name, ip, port, "", ""); + mCA = ca; + mContinentCode = continentCode; + mCountry = country; + mCountryCode = countryCode; + mRegion = region; + mUrl = url; + } + + public String getCA() { + return mCA; + } + + public String getContinentCode() { + return mContinentCode; + } + + public String getCountry() { + return mCountry; + } + + public String getCountryCode() { + return mCountryCode; + } + + public String getRegion() { + return mRegion; + } + + public String getUrl() { + return mUrl; + } +} diff --git a/app/src/main/java/se/lublin/mumla/drawable/CircleDrawable.java b/app/src/main/java/se/lublin/mumla/drawable/CircleDrawable.java new file mode 100644 index 00000000..2cb2cdb6 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/drawable/CircleDrawable.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.drawable; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; + +import se.lublin.mumla.R; + +/** + * A drawable containing a circular bitmap in the style of @drawable/outline_circle_talking_off. + * Created by andrew on 19/10/14. + */ +public class CircleDrawable extends Drawable { + public static final int STROKE_WIDTH_DP = 1; + private Resources mResources; + private Bitmap mBitmap; + private Paint mPaint; + private Paint mStrokePaint; + private ConstantState mConstantState; + + public CircleDrawable(Resources resources, Bitmap bitmap) { + mResources = resources; + mBitmap = bitmap; + + mPaint = new Paint(); + mPaint.setShader(new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); + mPaint.setDither(true); + mPaint.setAntiAlias(true); + + mStrokePaint = new Paint(); + mStrokePaint.setDither(true); + mStrokePaint.setAntiAlias(true); + mStrokePaint.setColor(resources.getColor(R.color.ripple_talk_state_disabled)); + float strokeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + STROKE_WIDTH_DP, resources.getDisplayMetrics()); + mStrokePaint.setStrokeWidth(strokeWidth); + mStrokePaint.setStyle(Paint.Style.STROKE); + + mConstantState = new ConstantState() { + @Override + public Drawable newDrawable() { + return new CircleDrawable(mResources, mBitmap); + } + + @Override + public int getChangingConfigurations() { + return 0; + } + }; + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + + RectF bitmapRect = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight()); + Matrix matrix = new Matrix(); + matrix.setRectToRect(bitmapRect, new RectF(bounds), Matrix.ScaleToFit.CENTER); + mPaint.getShader().setLocalMatrix(matrix); + } + + @Override + public void draw(Canvas canvas) { + RectF imageRect = new RectF(getBounds()); + RectF strokeRect = new RectF(getBounds()); + // Default stroke drawing is both inset and outset. + strokeRect.inset(mStrokePaint.getStrokeWidth()/2, + mStrokePaint.getStrokeWidth()/2); + + canvas.drawOval(imageRect, mPaint); + canvas.drawOval(strokeRect, mStrokePaint); + } + + @Override + public void setAlpha(int alpha) { + + } + + @Override + public void setColorFilter(ColorFilter cf) { + + } + + @Override + public int getOpacity() { + return PixelFormat.UNKNOWN; + } + + @Override + public ConstantState getConstantState() { + return mConstantState; + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/AboutSettingsFragment.java b/app/src/main/java/se/lublin/mumla/preference/AboutSettingsFragment.java new file mode 100644 index 00000000..657ff1e8 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/AboutSettingsFragment.java @@ -0,0 +1,50 @@ +package se.lublin.mumla.preference; + +import static java.util.Objects.requireNonNull; +import static se.lublin.mumla.app.DialogUtils.showAllNewsDialog; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.preference.Preference; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import se.lublin.mumla.BuildConfig; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; + +public class AboutSettingsFragment extends MumlaPreferenceFragment { + private static final String VERSION_KEY = "version"; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings_about, rootKey); + + String summary = String.format("%s (code %s)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE); + if (BuildConfig.FLAVOR.equals("foss")) { + summary += "\nFOSS flavor"; + } else if (BuildConfig.FLAVOR.equals("beta")) { + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + summary += String.format("\nBeta flavor with versioncode %s", BuildConfig.VERSION_CODE); + summary += String.format("\nBuildtime %s UTC", df.format(new Date(BuildConfig.TIMESTAMP))); + } else if (BuildConfig.FLAVOR.equals("donation")) { + summary += String.format("\n*) %s", getString(R.string.donation_thanks)); + } + Preference versionPreference = getPreferenceScreen().findPreference(VERSION_KEY); + requireNonNull(versionPreference).setSummary(summary); + requireNonNull(versionPreference).setOnPreferenceClickListener(preference -> { + Settings.getInstance(requireContext()).resetNewsShownVersion(); + return true; + }); + Preference showNewsPreference = getPreferenceScreen().findPreference("showNews"); + requireNonNull(showNewsPreference).setOnPreferenceClickListener(preference -> { + showAllNewsDialog(requireContext()); + return true; + }); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/AppearanceSettingsFragment.java b/app/src/main/java/se/lublin/mumla/preference/AppearanceSettingsFragment.java new file mode 100644 index 00000000..5b3ff517 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/AppearanceSettingsFragment.java @@ -0,0 +1,38 @@ +package se.lublin.mumla.preference; + +import static java.util.Locale.forLanguageTag; +import static se.lublin.mumla.Settings.PREF_LANGUAGE; + +import android.os.Bundle; + +import androidx.preference.ListPreference; + +import java.util.Locale; + +import se.lublin.mumla.R; + +public class AppearanceSettingsFragment extends MumlaPreferenceFragment { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings_appearance, rootKey); + + ListPreference languagePreference = findPreference(PREF_LANGUAGE); + if (languagePreference != null) { + String[] codes = getResources().getStringArray(R.array.languageValues); + String[] listNames = new String[codes.length + 1]; + String[] listCodes = new String[codes.length + 1]; + listNames[0] = getString(R.string.language_system); + listCodes[0] = "system"; + int dest = 1; + for (String code : codes) { + Locale locale = forLanguageTag(code); + listNames[dest] = locale.getDisplayName(locale); + listCodes[dest] = code; + dest++; + } + languagePreference.setEntries(listNames); + languagePreference.setEntryValues(listCodes); + } + + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/AudioSettingsFragment.java b/app/src/main/java/se/lublin/mumla/preference/AudioSettingsFragment.java new file mode 100644 index 00000000..bfac3ec9 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/AudioSettingsFragment.java @@ -0,0 +1,79 @@ +package se.lublin.mumla.preference; + +import static java.util.Objects.requireNonNull; +import static se.lublin.mumla.Settings.DEFAULT_ECHO_CANCELLATION_METHOD; +import static se.lublin.mumla.Settings.PREF_ECHO_CANCELLATION_METHOD; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.audiofx.AcousticEchoCanceler; +import android.os.Bundle; + +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; + +public class AudioSettingsFragment extends MumlaPreferenceFragment { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings_audio, rootKey); + + ListPreference inputPreference = getPreferenceScreen().findPreference(Settings.PREF_INPUT_METHOD); + requireNonNull(inputPreference).setOnPreferenceChangeListener((preference, newValue) -> { + updateAudioDependents(getPreferenceScreen(), (String) newValue); + return true; + }); + + // Scan each bitrate and determine if the device supports it + ListPreference inputQualityPreference = getPreferenceScreen().findPreference(Settings.PREF_INPUT_RATE); + String[] bitrateNames = new String[requireNonNull(inputQualityPreference).getEntryValues().length]; + for (int x = 0; x < bitrateNames.length; x++) { + int bitrate = Integer.parseInt(inputQualityPreference.getEntryValues()[x].toString()); + boolean supported = AudioRecord.getMinBufferSize(bitrate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) > 0; + bitrateNames[x] = bitrate + "Hz" + (supported ? "" : " (unsupported)"); + } + inputQualityPreference.setEntries(bitrateNames); + + ListPreference echoCancellationPref = findPreference(PREF_ECHO_CANCELLATION_METHOD); + if (echoCancellationPref != null) { + if (!AcousticEchoCanceler.isAvailable()) { + // android.media.audiofx.AcousticEchoCanceler is unavailable + removeListEntry(echoCancellationPref, "system"); + } + // Fallback to default ("none") if unset, or current value is no longer available + String current = echoCancellationPref.getValue(); + if (current == null || !Arrays.asList(echoCancellationPref.getEntryValues()).contains(current)) { + echoCancellationPref.setValue(DEFAULT_ECHO_CANCELLATION_METHOD); + } + } + + updateAudioDependents(getPreferenceScreen(), inputPreference.getValue()); + } + + private void removeListEntry(ListPreference pref, String valueToRemove) { + List entries = new ArrayList<>(Arrays.asList(pref.getEntries())); + List values = new ArrayList<>(Arrays.asList(pref.getEntryValues())); + for (int i = 0; i < values.size(); i++) { + if (values.get(i).toString().equals(valueToRemove)) { + entries.remove(i); + values.remove(i); + } + } + pref.setEntries(entries.toArray(new CharSequence[0])); + pref.setEntryValues(values.toArray(new CharSequence[0])); + } + + private static void updateAudioDependents(PreferenceScreen screen, String inputMethod) { + PreferenceCategory pttCategory = screen.findPreference("ptt_settings"); + PreferenceCategory vadCategory = screen.findPreference("vad_settings"); + requireNonNull(pttCategory).setEnabled(Settings.ARRAY_INPUT_METHOD_PTT.equals(inputMethod)); + requireNonNull(vadCategory).setEnabled(Settings.ARRAY_INPUT_METHOD_VOICE.equals(inputMethod)); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/AuthenticationSettingsFragment.java b/app/src/main/java/se/lublin/mumla/preference/AuthenticationSettingsFragment.java new file mode 100644 index 00000000..03f9b261 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/AuthenticationSettingsFragment.java @@ -0,0 +1,11 @@ +package se.lublin.mumla.preference; + +import android.os.Bundle; +import se.lublin.mumla.R; + +public class AuthenticationSettingsFragment extends MumlaPreferenceFragment { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings_authentication, rootKey); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/CertificateExportActivity.java b/app/src/main/java/se/lublin/mumla/preference/CertificateExportActivity.java new file mode 100644 index 00000000..c8cc3896 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/CertificateExportActivity.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import static android.os.Build.VERSION.SDK_INT; + +import android.Manifest; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.util.Log; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.documentfile.provider.DocumentFile; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +import se.lublin.mumla.R; +import se.lublin.mumla.db.DatabaseCertificate; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; + +/** + * Created by andrew on 12/01/16. + */ +public class CertificateExportActivity extends AppCompatActivity implements DialogInterface.OnClickListener { + private static final String TAG = CertificateExportActivity.class.getName(); + + /** + * The name of the directory to export to on external storage. + */ + private static final String EXTERNAL_STORAGE_DIR = "Mumla"; + + private MumlaDatabase mDatabase; + private List mCertificates; + + private final ActivityResultLauncher documentCreator = + registerForActivityResult(new CreateDocument(), this::onDocumentCreated); + private static final int PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 2; + private DatabaseCertificate mCertificatePending = null; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mDatabase = new MumlaSQLiteDatabase(this); + mCertificates = mDatabase.getCertificates(); + + CharSequence[] labels = new CharSequence[mCertificates.size()]; + for (int i = 0; i < labels.length; i++) { + labels[i] = mCertificates.get(i).getName(); + } + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.pref_export_certificate_title) + .setItems(labels, this) + .setOnCancelListener(dialog -> finish()) + .show(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mDatabase.close(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + DatabaseCertificate certificate = mCertificates.get(which); + if (SDK_INT >= Build.VERSION_CODES.R) { + // TODO Should always use this method? + mCertificatePending = certificate; + documentCreator.launch(certificate.getName()); + } else { + saveCertificateClassic(certificate); + } + } + + private void onDocumentCreated(Uri uri) { + if (uri != null && mCertificatePending != null) { + try { + OutputStream os = getContentResolver().openOutputStream(uri); + DocumentFile df = DocumentFile.fromSingleUri(this, uri); + writeCertificate(os, mCertificatePending, df != null ? df.getName() : ""); + } catch (FileNotFoundException e) { + showErrorDialog(R.string.externalStorageUnavailable); + Log.w(TAG, "FileNotFound on output file picked by user?!"); + } + } else if (mCertificatePending == null) { + Log.w(TAG, "No pending certificate after user picked output file"); + } + finish(); + } + + private void saveCertificateClassic(DatabaseCertificate certificate) { + if (ContextCompat.checkSelfPermission(CertificateExportActivity.this, + Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(CertificateExportActivity.this, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE); + mCertificatePending = certificate; + return; + } + + if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + showErrorDialog(R.string.externalStorageUnavailable); + return; + } + File storageDirectory = Environment.getExternalStorageDirectory(); + File mumlaDirectory = new File(storageDirectory, EXTERNAL_STORAGE_DIR); + if (!mumlaDirectory.exists() && !mumlaDirectory.mkdir()) { + showErrorDialog(R.string.externalStorageUnavailable); + return; + } + File outputFile = new File(mumlaDirectory, certificate.getName()); + FileOutputStream fos; + try { + fos = new FileOutputStream(outputFile); + } catch (FileNotFoundException e) { + showErrorDialog(R.string.externalStorageUnavailable); + return; + } + writeCertificate(fos, certificate, outputFile.getAbsolutePath()); + finish(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (mCertificatePending != null) { + saveCertificateClassic(mCertificatePending); + } else { + Log.w(TAG, "No pending certificate after permission was granted"); + } + } else { + Toast.makeText(CertificateExportActivity.this, getString(R.string.grant_perm_storage), + Toast.LENGTH_LONG).show(); + } + mCertificatePending = null; + } + } + + private void writeCertificate(OutputStream fos, DatabaseCertificate cert, String path) { + byte[] data = mDatabase.getCertificateData(cert.getId()); + try { + BufferedOutputStream bos = new BufferedOutputStream(fos); + bos.write(data); + bos.close(); + Toast.makeText(this, getString(R.string.export_success, path), Toast.LENGTH_LONG).show(); + } catch (IOException e) { + e.printStackTrace(); + showErrorDialog(R.string.error_writing_to_storage); + } + } + + private void showErrorDialog(int resourceId) { + new MaterialAlertDialogBuilder(this) + .setMessage(resourceId) + .setPositiveButton(android.R.string.ok, null) + .show(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/CertificateGenerateActivity.java b/app/src/main/java/se/lublin/mumla/preference/CertificateGenerateActivity.java new file mode 100644 index 00000000..9ccd49c3 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/CertificateGenerateActivity.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.DatabaseCertificate; + +/** + * Created by andrew on 12/01/16. + */ +public class CertificateGenerateActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + MumlaCertificateGenerateTask task = new MumlaCertificateGenerateTask(this) { + @Override + protected void onPostExecute(DatabaseCertificate result) { + super.onPostExecute(result); + if (result == null) { + finish(); + return; + } + + Settings settings = Settings.getInstance(CertificateGenerateActivity.this); + settings.setDefaultCertificateId(result.getId()); + showCompletionDialog(result); + } + }; + task.execute(); + } + + private void showCompletionDialog(DatabaseCertificate result) { + new MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.generateCertSuccess, result.getName())) + .setPositiveButton(android.R.string.ok, null) + .setOnDismissListener(dialog -> finish()) + .show(); + } + +} diff --git a/app/src/main/java/se/lublin/mumla/preference/CertificateImportActivity.java b/app/src/main/java/se/lublin/mumla/preference/CertificateImportActivity.java new file mode 100644 index 00000000..52cbb1d0 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/CertificateImportActivity.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.text.InputType; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.spongycastle.jce.provider.BouncyCastleProvider; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.UUID; + +import se.lublin.mumla.R; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; + +/** + * Created by andrew on 11/01/16. + */ +public class CertificateImportActivity extends AppCompatActivity { + public static final int REQUEST_FILE = 0; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT); + fileIntent.setType("*/*"); + fileIntent.addCategory(Intent.CATEGORY_OPENABLE); + startActivityForResult(fileIntent, REQUEST_FILE); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != REQUEST_FILE) + return; + + if (resultCode == RESULT_CANCELED) { + finish(); + return; + } + + Uri uri = data.getData(); + InputStream is; + try { + is = getContentResolver().openInputStream(uri); + } catch (FileNotFoundException e) { + e.printStackTrace(); + // FIXME(acomminos) + finish(); + return; + } + + String displayName; + Cursor cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + displayName = cursor.getString(0); + } else { + displayName = UUID.randomUUID().toString() + ".p12"; + } + if (cursor != null) + cursor.close(); + + storeKeystore(new char[0], displayName, is); + } + + private void storeKeystore(final char[] password, final String fileName, final InputStream input) { + KeyStore keyStore; + try { + keyStore = KeyStore.getInstance("PKCS12", new BouncyCastleProvider()); + keyStore.load(input, password); + } catch (CertificateException e) { + // A problem occurred when reading the stream; interpret this as a password being + // required. Request a password from the user and reattempt decryption. + // FIXME(acomminos): examine p12 file's SafeBags to determine the presence of a password + final EditText passwordField = new EditText(this); + passwordField.setHint(R.string.password); + passwordField.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.decrypt_certificate) + .setView(passwordField) + .setOnCancelListener(dialog -> finish()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> + storeKeystore(passwordField.getText().toString().toCharArray(), fileName, input)) + .show(); + return; + } catch (KeyStoreException|IOException|NoSuchAlgorithmException e) { + e.printStackTrace(); + Toast.makeText(this, R.string.invalid_certificate, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try { + keyStore.store(output, new char[0]); + } catch (KeyStoreException|IOException|NoSuchAlgorithmException|CertificateException e) { + e.printStackTrace(); + Toast.makeText(this, R.string.certificate_load_failed, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + MumlaDatabase database = new MumlaSQLiteDatabase(this); + database.addCertificate(fileName, output.toByteArray()); + database.close(); + + Toast.makeText(this, getString(R.string.certificate_import_success, fileName), + Toast.LENGTH_LONG).show(); + finish(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/CertificateSelectActivity.java b/app/src/main/java/se/lublin/mumla/preference/CertificateSelectActivity.java new file mode 100644 index 00000000..0a634c9d --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/CertificateSelectActivity.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import android.content.DialogInterface; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.DatabaseCertificate; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; + +/** + * Created by andrew on 11/01/16. + */ +public class CertificateSelectActivity extends AppCompatActivity implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener { + private List mCertificates; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Settings settings = Settings.getInstance(this); + mCertificates = new ArrayList<>(); + mCertificates.add(new NoCertificateItem(getString(R.string.no_certificate), settings)); + MumlaDatabase database = new MumlaSQLiteDatabase(this); + for (DatabaseCertificate certificate : database.getCertificates()) { + mCertificates.add(new CertificateItem(certificate, settings)); + } + database.close(); + + showCertificateSelectionDialog(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + ICertificateItem certificate = mCertificates.get(which); + certificate.onActivate(); + finish(); + } + + private void showCertificateSelectionDialog() { + int defaultCertificatePosition = -1; + for (int i = 0; i < mCertificates.size(); i++) { + if (mCertificates.get(i).isDefault()) { + defaultCertificatePosition = i; + break; + } + } + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this); + builder.setTitle(R.string.pref_certificate_title); + ArrayAdapter adapter = new ArrayAdapter(this, android.R.layout.select_dialog_singlechoice, mCertificates) { + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + View view = super.getView(position, convertView, parent); + TextView textView = view.findViewById(android.R.id.text1); + textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); + return view; + } + }; + builder.setSingleChoiceItems(adapter, defaultCertificatePosition, this); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show().setOnDismissListener(this); + } + + @Override + public void onDismiss(DialogInterface dialog) { + finish(); + } + + private interface ICertificateItem { + void onActivate(); + boolean isDefault(); + } + + private static class CertificateItem implements ICertificateItem { + private final DatabaseCertificate mCertificate; + private final Settings mSettings; + + public CertificateItem(DatabaseCertificate certificate, Settings settings) { + mCertificate = certificate; + mSettings = settings; + } + + @Override + public void onActivate() { + mSettings.setDefaultCertificateId(mCertificate.getId()); + } + + @Override + public boolean isDefault() { + return mSettings.getDefaultCertificate() == mCertificate.getId(); + } + + @Override + public String toString() { + return mCertificate.getName(); + } + } + + private static class NoCertificateItem implements ICertificateItem { + private final String mText; + private final Settings mSettings; + + public NoCertificateItem(String text, Settings settings) { + mText = text; + mSettings = settings; + } + + @Override + public void onActivate() { + mSettings.disableCertificate(); + } + + @Override + public boolean isDefault() { + return !mSettings.isUsingCertificate(); + } + + @Override + public String toString() { + return mText; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/GeneralSettingsFragment.java b/app/src/main/java/se/lublin/mumla/preference/GeneralSettingsFragment.java new file mode 100644 index 00000000..1928a74b --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/GeneralSettingsFragment.java @@ -0,0 +1,22 @@ +package se.lublin.mumla.preference; + +import static java.util.Objects.requireNonNull; + +import android.os.Bundle; + +import androidx.preference.Preference; + +import info.guardianproject.netcipher.proxy.OrbotHelper; +import se.lublin.mumla.R; + +public class GeneralSettingsFragment extends MumlaPreferenceFragment { + private static final String USE_TOR_KEY = "useTor"; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings_general, rootKey); + + Preference useOrbotPreference = getPreferenceScreen().findPreference(USE_TOR_KEY); + requireNonNull(useOrbotPreference).setEnabled(OrbotHelper.isOrbotInstalled(requireContext())); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/KeySelectDialogPreference.java b/app/src/main/java/se/lublin/mumla/preference/KeySelectDialogPreference.java new file mode 100644 index 00000000..c020de46 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/KeySelectDialogPreference.java @@ -0,0 +1,16 @@ +package se.lublin.mumla.preference; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import se.lublin.mumla.R; + +public class KeySelectDialogPreference extends DialogPreference { + public KeySelectDialogPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + setDialogLayoutResource(R.layout.dialog_keyselect_preference); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/KeySelectPreferenceDialogFragment.java b/app/src/main/java/se/lublin/mumla/preference/KeySelectPreferenceDialogFragment.java new file mode 100644 index 00000000..f19d4475 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/KeySelectPreferenceDialogFragment.java @@ -0,0 +1,98 @@ +package se.lublin.mumla.preference; + +import static java.util.Objects.requireNonNull; + +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.View; +import android.view.View.OnKeyListener; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.PreferenceDialogFragmentCompat; + +import se.lublin.mumla.R; + +public class KeySelectPreferenceDialogFragment extends PreferenceDialogFragmentCompat implements OnKeyListener { + private TextView mValueView; + private int mCurrentValue; + + public static KeySelectPreferenceDialogFragment newInstance(String key) { + final KeySelectPreferenceDialogFragment fragment = new KeySelectPreferenceDialogFragment(); + final Bundle b = new Bundle(1); + b.putString(ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + @Override + protected void onPrepareDialogBuilder(@NonNull AlertDialog.Builder builder) { + super.onPrepareDialogBuilder(builder); + + builder.setNeutralButton(R.string.reset_key, (dialog, which) -> { + KeySelectDialogPreference preference = (KeySelectDialogPreference) getPreference(); + mCurrentValue = 0; + // A NeutralButton causes onDialogClosed to be called with positiveResult==false, + // so we persist manually here. + if (preference.callChangeListener(mCurrentValue)) { + requireNonNull(preference.getSharedPreferences()) + .edit().putInt(preference.getKey(), mCurrentValue).apply(); + } + }); + } + + @Override + protected void onBindDialogView(@NonNull View view) { + super.onBindDialogView(view); + + view.setOnKeyListener(this); + view.setFocusableInTouchMode(true); + view.requestFocus(); + + mValueView = view.findViewById(R.id.key_select_value_view); + KeySelectDialogPreference preference = (KeySelectDialogPreference) getPreference(); + mCurrentValue = requireNonNull(preference.getSharedPreferences()) + .getInt(preference.getKey(), 0); + updateValueView(); + } + + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + if (keyCode != KeyEvent.KEYCODE_BACK) { + mCurrentValue = keyCode; + updateValueView(); + } else { + dismiss(); + } + return true; + } + + private void updateValueView() { + if (mCurrentValue == 0) { + mValueView.setText(R.string.no_ptt_key); + } else { + final String stripPrefix = "KEYCODE_"; + String keyName = KeyEvent.keyCodeToString(mCurrentValue); + if (keyName.startsWith(stripPrefix)) { + keyName = keyName.substring(stripPrefix.length()); + } + mValueView.setText(keyName); + } + } + + @Override + public void onDialogClosed(boolean positiveResult) { + if (positiveResult) { + KeySelectDialogPreference preference = (KeySelectDialogPreference) getPreference(); + if (preference.callChangeListener(mCurrentValue)) { + requireNonNull(preference.getSharedPreferences()) + .edit().putInt(preference.getKey(), mCurrentValue).apply(); + } + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/MumlaCertificateGenerateTask.java b/app/src/main/java/se/lublin/mumla/preference/MumlaCertificateGenerateTask.java new file mode 100644 index 00000000..64b3739d --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/MumlaCertificateGenerateTask.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import android.content.Context; +import android.os.AsyncTask; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.ByteArrayOutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import se.lublin.humla.net.HumlaCertificateGenerator; +import se.lublin.mumla.R; +import se.lublin.mumla.db.DatabaseCertificate; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; + +public class MumlaCertificateGenerateTask extends AsyncTask { + private static final String DATE_FORMAT = "yyyy-MM-dd-HH-mm-ss"; + + private Context context; + private final boolean showProgressDialog; + private AlertDialog loadingDialog; + + public MumlaCertificateGenerateTask(Context context) { + this(context, true); + } + + /** + * Creates a certificate generation task. + * + * @param showProgressDialog false for the automatic first-run path, where a modal dialog + * would interrupt radio startup + */ + public MumlaCertificateGenerateTask(Context context, boolean showProgressDialog) { + this.context = context; + this.showProgressDialog = showProgressDialog; + } + + @Override + protected void onPreExecute() { + super.onPreExecute(); + + if (showProgressDialog) { + loadingDialog = new MaterialAlertDialogBuilder(context) + .setTitle(R.string.generateCertProgress) + .setView(R.layout.dialog_progress) + .setCancelable(false) + .create(); + loadingDialog.show(); + } + } + @Override + protected DatabaseCertificate doInBackground(Void... params) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + HumlaCertificateGenerator.generateCertificate(baos); + + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.getDefault()); + String fileName = context.getString(R.string.certificate_export_format, dateFormat.format(new Date())); + + MumlaDatabase database = new MumlaSQLiteDatabase(context); + DatabaseCertificate dc = database.addCertificate(fileName, baos.toByteArray()); + database.close(); + return dc; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + @Override + protected void onPostExecute(DatabaseCertificate result) { + super.onPostExecute(result); + if(result == null && showProgressDialog) { + Toast.makeText(context, R.string.generateCertFailure, Toast.LENGTH_SHORT).show(); + } + + if (loadingDialog != null) { + loadingDialog.dismiss(); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/MumlaPreferenceFragment.java b/app/src/main/java/se/lublin/mumla/preference/MumlaPreferenceFragment.java new file mode 100644 index 00000000..378340b1 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/MumlaPreferenceFragment.java @@ -0,0 +1,67 @@ +package se.lublin.mumla.preference; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.Preference; +import androidx.preference.PreferenceDialogFragmentCompat; +import androidx.preference.PreferenceFragmentCompat; + +import se.lublin.mumla.R; + +public abstract class MumlaPreferenceFragment extends PreferenceFragmentCompat { + @Override + public boolean onPreferenceTreeClick(Preference preference) { + final String fragment = preference.getFragment(); + if (fragment != null) { + Bundle bundle = new Bundle(); + bundle.putString("fragmentClassName", fragment); + bundle.putCharSequence("title", preference.getTitle()); + getParentFragmentManager().setFragmentResult("launchFragment", bundle); + return true; + } + return super.onPreferenceTreeClick(preference); + } + + @Override + public void onDisplayPreferenceDialog(@NonNull Preference preference) { + if (preference instanceof SeekBarDialogPreference) { + if (getParentFragmentManager().findFragmentByTag("androidx.preference.PreferenceFragment.DIALOG") != null) { + return; + } + final PreferenceDialogFragmentCompat dialogFragment = SeekBarPreferenceDialogFragment.newInstance(preference.getKey()); + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getParentFragmentManager(), "androidx.preference.PreferenceFragment.DIALOG"); + return; + } + if (preference instanceof KeySelectDialogPreference) { + if (getParentFragmentManager().findFragmentByTag("androidx.preference.PreferenceFragment.DIALOG") != null) { + return; + } + final PreferenceDialogFragmentCompat dialogFragment = KeySelectPreferenceDialogFragment.newInstance(preference.getKey()); + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getParentFragmentManager(), "androidx.preference.PreferenceFragment.DIALOG"); + return; + } + super.onDisplayPreferenceDialog(preference); + } + + @Override + public void onResume() { + super.onResume(); + + ActionBar actionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar(); + if (actionBar == null) { + return; + } + Bundle arguments = getArguments(); + CharSequence title = (arguments != null) ? arguments.getCharSequence("title") : null; + if (title == null) { + actionBar.setTitle(R.string.action_settings); + return; + } + actionBar.setTitle(title); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/SeekBarDialogPreference.java b/app/src/main/java/se/lublin/mumla/preference/SeekBarDialogPreference.java new file mode 100644 index 00000000..8724e766 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/SeekBarDialogPreference.java @@ -0,0 +1,32 @@ +package se.lublin.mumla.preference; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import se.lublin.mumla.R; + +public class SeekBarDialogPreference extends DialogPreference { + public final int mMax; + public final int mMin; + public final int mMultiplier; + public final String mSuffix; + public final int mDefaultValue; + + public SeekBarDialogPreference(Context context, AttributeSet attrs) { + super(context, attrs); + + try (TypedArray a = context.getTheme().obtainStyledAttributes( + attrs, R.styleable.SeekBarDialogPreference, 0, 0)) { + mMax = a.getInt(R.styleable.SeekBarDialogPreference_max, 100); + mMin = a.getInt(R.styleable.SeekBarDialogPreference_min, 0); + mMultiplier = a.getInt(R.styleable.SeekBarDialogPreference_multiplier, 1); + mSuffix = a.getString(R.styleable.SeekBarDialogPreference_android_text); + mDefaultValue = a.getInt(R.styleable.SeekBarDialogPreference_android_defaultValue, 0); + } + + setDialogLayoutResource(R.layout.dialog_seekbar_preference); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/SeekBarPreferenceDialogFragment.java b/app/src/main/java/se/lublin/mumla/preference/SeekBarPreferenceDialogFragment.java new file mode 100644 index 00000000..48feb0e3 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/SeekBarPreferenceDialogFragment.java @@ -0,0 +1,89 @@ +package se.lublin.mumla.preference; + +import static java.util.Objects.requireNonNull; + +import android.os.Bundle; +import android.view.View; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceDialogFragmentCompat; + +import se.lublin.mumla.R; + +public class SeekBarPreferenceDialogFragment extends PreferenceDialogFragmentCompat { + + private TextView mValueView; + private int mMultiplier; + private int mMin; + private int mCurrentValue; + private String mSuffix; + + public static SeekBarPreferenceDialogFragment newInstance(String key) { + final SeekBarPreferenceDialogFragment fragment = new SeekBarPreferenceDialogFragment(); + final Bundle b = new Bundle(1); + b.putString(ARG_KEY, key); + fragment.setArguments(b); + return fragment; + } + + @Override + protected void onBindDialogView(@NonNull View view) { + super.onBindDialogView(view); + + SeekBar seekBar = view.findViewById(R.id.seek_bar); + mValueView = view.findViewById(R.id.seek_bar_value_view); + + SeekBarDialogPreference preference = (SeekBarDialogPreference) getPreference(); + + mMultiplier = preference.mMultiplier; + mMin = preference.mMin; + mSuffix = preference.mSuffix; + // The persisted value is always multiplied, but the default value in the XML is not (nor + // are the min and max values in the XML). Make this variable contain the correct + // multiplier value in both cases. + mCurrentValue = requireNonNull(preference.getSharedPreferences()) + .getInt(preference.getKey(), preference.mDefaultValue * mMultiplier); + updateValueView(); + + seekBar.setMax(preference.mMax - mMin); + // The persisted value is multiplied, scale it down + seekBar.setProgress((mCurrentValue / mMultiplier) - mMin); + + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + mCurrentValue = (mMin + progress) * mMultiplier; + updateValueView(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + } + + private void updateValueView() { + String t = String.valueOf(mCurrentValue); + mValueView.setText(mSuffix == null ? t : t.concat(mSuffix)); + } + + @Override + public void onDialogClosed(boolean positiveResult) { + if (positiveResult) { + SeekBarDialogPreference preference = (SeekBarDialogPreference) getPreference(); + // Persisting the multiplied value, as was always done + if (preference.callChangeListener(mCurrentValue)) { + requireNonNull(preference.getSharedPreferences()) + .edit().putInt(preference.getKey(), mCurrentValue).apply(); + } + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/ServerCertificateClearActivity.java b/app/src/main/java/se/lublin/mumla/preference/ServerCertificateClearActivity.java new file mode 100644 index 00000000..26fdcc17 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/ServerCertificateClearActivity.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.preference; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import se.lublin.mumla.R; +import se.lublin.mumla.util.MumlaTrustStore; + +/** + * Created by andrew on 13/01/16. + */ +public class ServerCertificateClearActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + MumlaTrustStore.clearTrustStore(this); + Toast.makeText(this, R.string.trust_cleared, Toast.LENGTH_LONG).show(); + finish(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/preference/SettingsActivity.java b/app/src/main/java/se/lublin/mumla/preference/SettingsActivity.java new file mode 100644 index 00000000..b6de996e --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/preference/SettingsActivity.java @@ -0,0 +1,70 @@ +package se.lublin.mumla.preference; + +import android.os.Bundle; + +import androidx.activity.OnBackPressedCallback; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; + +import se.lublin.mumla.R; + +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.action_settings); + } + + toolbar.setNavigationOnClickListener(v -> getOnBackPressedDispatcher().onBackPressed()); + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (!getSupportFragmentManager().popBackStackImmediate()) { + // Finish activity if nothing was popped from stack + finish(); + } + } + }); + + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings_container, new RootPreferenceFragment()) + .commit(); + } + + getSupportFragmentManager().setFragmentResultListener("launchFragment", this, (requestKey, result) -> { + String fragmentClassName = result.getString("fragmentClassName"); + if (fragmentClassName != null) { + try { + Class fragmentClass = Class.forName(fragmentClassName); + Fragment fragment = (Fragment) fragmentClass.newInstance(); + // Pass it on to the fragment so its onResume can set the title, see MumlaPreferenceFragment + fragment.setArguments(result); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings_container, fragment) + .addToBackStack(null) + .commit(); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + + public static class RootPreferenceFragment extends MumlaPreferenceFragment { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.preference_headers, rootKey); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/AccessTokenResolver.java b/app/src/main/java/se/lublin/mumla/radio/AccessTokenResolver.java new file mode 100644 index 00000000..8c0d3b2c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/AccessTokenResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** Resolves public access tokens for one configured Minimum channel. */ +public final class AccessTokenResolver { + private AccessTokenResolver() { + } + + /** + * Returns non-blank public tokens for one channel, without duplicates. Protected references + * remain unresolved until a device-local secure store is available. + */ + public static List resolve(JSONObject channel) { + if (channel == null) { + return Collections.emptyList(); + } + Object accessValue = channel.opt("access"); + if (!(accessValue instanceof JSONObject)) { + return Collections.emptyList(); + } + JSONObject access = (JSONObject) accessValue; + if (!"public".equals(access.opt("mode"))) { + return Collections.emptyList(); + } + + Set tokens = new LinkedHashSet<>(); + addToken(tokens, access.opt("token")); + Object tokenArray = access.opt("tokens"); + if (tokenArray instanceof JSONArray) { + JSONArray values = (JSONArray) tokenArray; + for (int index = 0; index < values.length(); index++) { + addToken(tokens, values.opt(index)); + } + } + return tokens.isEmpty() + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(tokens)); + } + + private static void addToken(Set tokens, Object value) { + if (value instanceof String) { + String token = ((String) value).trim(); + if (!token.isEmpty()) { + tokens.add(token); + } + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/ConfigTlsSocketFactory.java b/app/src/main/java/se/lublin/mumla/radio/ConfigTlsSocketFactory.java new file mode 100644 index 00000000..9e398ac0 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/ConfigTlsSocketFactory.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import se.lublin.mumla.R; + +/** Adds the current ISRG root to old Android trust stores without weakening hostname checks. */ +final class ConfigTlsSocketFactory extends SSLSocketFactory { + private final SSLSocketFactory delegate; + + static SSLSocketFactory create(Context context) throws IOException { + try { + X509TrustManager systemTrust = trustManager(null); + KeyStore bundledStore = KeyStore.getInstance(KeyStore.getDefaultType()); + bundledStore.load(null, null); + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Certificate root; + try (InputStream input = context.getResources().openRawResource(R.raw.isrg_root_x1)) { + root = certificateFactory.generateCertificate(input); + } + bundledStore.setCertificateEntry("isrg-root-x1", root); + X509TrustManager bundledTrust = trustManager(bundledStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, + new TrustManager[] {new CompositeTrustManager(systemTrust, bundledTrust)}, + new SecureRandom()); + return new ConfigTlsSocketFactory(sslContext.getSocketFactory()); + } catch (GeneralSecurityException exception) { + throw new IOException("cannot initialize config TLS trust", exception); + } + } + + private static X509TrustManager trustManager(KeyStore keyStore) + throws GeneralSecurityException { + TrustManagerFactory factory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore); + for (TrustManager manager : factory.getTrustManagers()) { + if (manager instanceof X509TrustManager) { + return (X509TrustManager) manager; + } + } + throw new GeneralSecurityException("X509 trust manager is unavailable"); + } + + private ConfigTlsSocketFactory(SSLSocketFactory delegate) { + this.delegate = delegate; + } + + @Override public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override public Socket createSocket(Socket socket, String host, int port, boolean autoClose) + throws IOException { + return enableTls12(delegate.createSocket(socket, host, port, autoClose)); + } + + @Override public Socket createSocket(String host, int port) throws IOException { + return enableTls12(delegate.createSocket(host, port)); + } + + @Override public Socket createSocket(String host, int port, InetAddress localHost, + int localPort) throws IOException { + return enableTls12(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + return enableTls12(delegate.createSocket(host, port)); + } + + @Override public Socket createSocket(InetAddress address, int port, + InetAddress localAddress, int localPort) + throws IOException { + return enableTls12(delegate.createSocket(address, port, localAddress, localPort)); + } + + private static Socket enableTls12(Socket socket) { + if (!(socket instanceof SSLSocket)) { + return socket; + } + SSLSocket sslSocket = (SSLSocket) socket; + for (String protocol : sslSocket.getSupportedProtocols()) { + if ("TLSv1.2".equals(protocol)) { + sslSocket.setEnabledProtocols(new String[] {"TLSv1.2"}); + break; + } + } + return sslSocket; + } + + private static final class CompositeTrustManager implements X509TrustManager { + private final X509TrustManager system; + private final X509TrustManager bundled; + + CompositeTrustManager(X509TrustManager system, X509TrustManager bundled) { + this.system = system; + this.bundled = bundled; + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + system.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws CertificateException { + try { + system.checkServerTrusted(chain, authType); + } catch (CertificateException systemFailure) { + try { + bundled.checkServerTrusted(chain, authType); + } catch (CertificateException bundledFailure) { + bundledFailure.addSuppressed(systemFailure); + throw bundledFailure; + } + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + X509Certificate[] systemIssuers = system.getAcceptedIssuers(); + X509Certificate[] bundledIssuers = bundled.getAcceptedIssuers(); + X509Certificate[] combined = new X509Certificate[ + systemIssuers.length + bundledIssuers.length]; + System.arraycopy(systemIssuers, 0, combined, 0, systemIssuers.length); + System.arraycopy(bundledIssuers, 0, combined, systemIssuers.length, + bundledIssuers.length); + return combined; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/DeviceConfigCredentialStore.java b/app/src/main/java/se/lublin/mumla/radio/DeviceConfigCredentialStore.java new file mode 100644 index 00000000..c9320d08 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/DeviceConfigCredentialStore.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** Stores the per-device config bearer credential outside the normal backup-able preferences. */ +public final class DeviceConfigCredentialStore { + static final int MAX_CREDENTIAL_BYTES = 4096; + private static final String CREDENTIAL_FILE = "device-config-credential"; + private static final String TEMP_FILE = "device-config-credential.tmp"; + + private final File directory; + private final Object lock = new Object(); + + public DeviceConfigCredentialStore(Context context) { + if (context == null) { + throw new IllegalArgumentException("context must not be null"); + } + Context applicationContext = context.getApplicationContext(); + if (applicationContext == null) { + applicationContext = context; + } + File noBackupDirectory = applicationContext.getNoBackupFilesDir(); + if (noBackupDirectory == null) { + noBackupDirectory = applicationContext.getFilesDir(); + } + if (noBackupDirectory == null) { + throw new IllegalStateException("app-private credential directory is unavailable"); + } + directory = new File(noBackupDirectory, "radio-config"); + } + + static DeviceConfigCredentialStore forDirectory(File directory) { + if (directory == null) { + throw new IllegalArgumentException("credential directory must not be null"); + } + return new DeviceConfigCredentialStore(directory, true); + } + + private DeviceConfigCredentialStore(File directory, boolean ignored) { + this.directory = directory; + } + + /** Returns the raw token, or null when no credential has been provisioned. */ + public String getCredential() throws IOException { + synchronized (lock) { + File credential = new File(directory, CREDENTIAL_FILE); + if (!credential.isFile()) { + return null; + } + return normalizeCredential(readLimited(credential)); + } + } + + /** Returns the HTTP Authorization value without exposing the token to callers that log it. */ + public String getAuthorizationHeader() throws IOException { + String credential = getCredential(); + return credential == null ? null : "Bearer " + credential; + } + + /** Atomically installs or rotates the credential after validating header-safe characters. */ + public void setCredential(String credential) throws IOException { + writeCredential(normalizeCredential(credential)); + } + + /** Installs a credential from a bounded protected provisioning stream. */ + public void setCredential(InputStream input) throws IOException { + if (input == null) { + throw new IllegalArgumentException("credential input must not be null"); + } + writeCredential(normalizeCredential(readLimited(input))); + } + + private void writeCredential(String normalized) throws IOException { + synchronized (lock) { + if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) { + throw new IOException("cannot create app-private credential directory"); + } + File temporary = new File(directory, TEMP_FILE); + File target = new File(directory, CREDENTIAL_FILE); + deleteIfPresent(temporary); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(normalized.getBytes(StandardCharsets.US_ASCII)); + output.getFD().sync(); + } + deleteIfPresent(target); + if (!temporary.renameTo(target)) { + deleteIfPresent(temporary); + throw new IOException("cannot install app-private credential"); + } + } + } + + /** Removes the credential without changing active, pending, or previous radio config. */ + public void clearCredential() throws IOException { + synchronized (lock) { + deleteIfPresent(new File(directory, CREDENTIAL_FILE)); + deleteIfPresent(new File(directory, TEMP_FILE)); + } + } + + static String normalizeCredential(String credential) { + if (credential == null) { + throw new IllegalArgumentException("device config credential is missing"); + } + String normalized = credential.trim(); + if (normalized.regionMatches(true, 0, "Bearer ", 0, "Bearer ".length())) { + normalized = normalized.substring("Bearer ".length()).trim(); + } + if (normalized.isEmpty() || normalized.length() > MAX_CREDENTIAL_BYTES) { + throw new IllegalArgumentException("invalid device config credential"); + } + for (int index = 0; index < normalized.length(); index++) { + char character = normalized.charAt(index); + if (character < 0x21 || character > 0x7e) { + throw new IllegalArgumentException("invalid device config credential"); + } + } + return normalized; + } + + private static String readLimited(File file) throws IOException { + try (FileInputStream input = new FileInputStream(file)) { + return readLimited(input); + } + } + + private static String readLimited(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[256]; + int total = 0; + int read; + while ((read = input.read(buffer)) != -1) { + total += read; + if (total > MAX_CREDENTIAL_BYTES) { + throw new IOException("device config credential is too large"); + } + output.write(buffer, 0, read); + } + return output.toString(StandardCharsets.US_ASCII.name()); + } + + private static void deleteIfPresent(File file) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException("cannot replace app-private credential"); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java b/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java new file mode 100644 index 00000000..e7b85d46 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.SharedPreferences; + +import java.security.SecureRandom; + +/** + * Creates and persists the public six-character identity used by the radio PoC. + * + * The value is deliberately unrelated to hardware identifiers. It survives app updates and + * reboots, while clearing app data causes the normal first-run path to create a new value. + */ +public final class DeviceIdentityManager { + public static final int DEVICE_ID_LENGTH = 6; + public static final String DEVICE_ID_PREFERENCE = "radio_device_id"; + + private static final String LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String DIGITS = "0123456789"; + private static final String ALPHANUMERIC = LETTERS + DIGITS; + + private final SharedPreferences preferences; + private final SecureRandom secureRandom; + + public DeviceIdentityManager(SharedPreferences preferences) { + this(preferences, new SecureRandom()); + } + + DeviceIdentityManager(SharedPreferences preferences, SecureRandom secureRandom) { + if (preferences == null) { + throw new IllegalArgumentException("preferences must not be null"); + } + if (secureRandom == null) { + throw new IllegalArgumentException("secureRandom must not be null"); + } + this.preferences = preferences; + this.secureRandom = secureRandom; + } + + /** Returns the existing identity or creates it once on first use. */ + public String getOrCreateDeviceId() { + String existing = preferences.getString(DEVICE_ID_PREFERENCE, null); + if (isValidDeviceId(existing)) { + return existing; + } + + String generated = generateDeviceId(); + preferences.edit().putString(DEVICE_ID_PREFERENCE, generated).apply(); + return generated; + } + + /** + * Generates a new identity for an explicitly authorized administrative action. + * Callers must protect the UI/action that invokes this method. + */ + public String regenerateDeviceIdForAdmin() { + String generated = generateDeviceId(); + preferences.edit().putString(DEVICE_ID_PREFERENCE, generated).apply(); + return generated; + } + + /** Assigns an externally managed lookup identity through an authorized provisioning path. */ + public void setDeviceIdForAdmin(String deviceId) { + if (!isValidDeviceId(deviceId)) { + throw new IllegalArgumentException("invalid device id"); + } + if (!preferences.edit().putString(DEVICE_ID_PREFERENCE, deviceId).commit()) { + throw new IllegalStateException("device id could not be persisted"); + } + } + + public static boolean isValidDeviceId(String value) { + if (value == null || value.length() != DEVICE_ID_LENGTH) { + return false; + } + + boolean hasLetter = false; + boolean hasDigit = false; + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (LETTERS.indexOf(character) >= 0) { + hasLetter = true; + } else if (DIGITS.indexOf(character) >= 0) { + hasDigit = true; + } else { + return false; + } + } + return hasLetter && hasDigit; + } + + private String generateDeviceId() { + char[] characters = new char[DEVICE_ID_LENGTH]; + characters[0] = LETTERS.charAt(secureRandom.nextInt(LETTERS.length())); + characters[1] = DIGITS.charAt(secureRandom.nextInt(DIGITS.length())); + for (int i = 2; i < characters.length; i++) { + characters[i] = ALPHANUMERIC.charAt(secureRandom.nextInt(ALPHANUMERIC.length())); + } + + // Fisher-Yates shuffle prevents the required letter/digit from having a fixed position. + for (int i = characters.length - 1; i > 0; i--) { + int swapIndex = secureRandom.nextInt(i + 1); + char temporary = characters[i]; + characters[i] = characters[swapIndex]; + characters[swapIndex] = temporary; + } + return new String(characters); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/MinimumHomeActivity.java b/app/src/main/java/se/lublin/mumla/radio/MinimumHomeActivity.java new file mode 100644 index 00000000..99feef64 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/MinimumHomeActivity.java @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.app.Activity; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.view.GestureDetector; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import se.lublin.mumla.R; +import se.lublin.mumla.service.MumlaService; + +/** + * A deliberately small radio-device dashboard. Each page has one large, recoverable action. + * Provisioning and boot can open it while OEM Launcher3 remains the Android HOME app. + */ +public final class MinimumHomeActivity extends Activity { + private static final int PAGE_MINIMUM = 0; + private static final int PAGE_SETTINGS = 1; + + private FrameLayout root; + private TextView pageIndicator; + private int page = PAGE_MINIMUM; + private GestureDetector gestureDetector; + private se.lublin.mumla.Settings settings; + private ToneGenerator pttRecoveryTone; + private final Handler identityHandler = new Handler(Looper.getMainLooper()); + private int pendingIdentityKey = KeyEvent.KEYCODE_UNKNOWN; + private long pendingIdentityStartedAt = -1L; + private boolean identityHoldCompleted; + private boolean identityOverlayVisible; + private TextView identityOverlayView; + private final Runnable identityToggleAction = () -> { + if (pendingIdentityKey == KeyEvent.KEYCODE_UNKNOWN) { + return; + } + identityHoldCompleted = true; + setIdentityOverlayVisible(!identityOverlayVisible); + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Window window = getWindow(); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.setStatusBarColor(Color.rgb(7, 25, 43)); + window.setNavigationBarColor(Color.rgb(7, 25, 43)); + settings = se.lublin.mumla.Settings.getInstance(this); + + gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent event) { + return true; + } + + @Override + public boolean onFling(MotionEvent start, MotionEvent end, float velocityX, float velocityY) { + float distanceX = end.getX() - start.getX(); + if (Math.abs(distanceX) > Math.abs(end.getY() - start.getY()) + && Math.abs(distanceX) > 24 && Math.abs(velocityX) > 50) { + showPage(distanceX < 0 ? page + 1 : page - 1); + return true; + } + return false; + } + }); + + root = new FrameLayout(this); + root.setBackgroundColor(Color.rgb(7, 25, 43)); + setContentView(root); + showPage(PAGE_MINIMUM); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (gestureDetector != null) { + gestureDetector.onTouchEvent(event); + } + return super.dispatchTouchEvent(event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (RadioPttKeyManager.isConfiguredPttKey(keyCode, settings) + && !RadioPttKeyManager.isMediaStyleKey(keyCode)) { + if (event.getRepeatCount() == 0) { + playPttRecoveryAlert(); + launchRadioForPttRecovery(); + } + return true; + } + if (RadioKeyActionPolicy.isIdentityToggleEvent( + RadioDeviceProfile.detectCurrent(), event)) { + beginIdentityToggle(keyCode, event); + return true; + } + if (isPreviousPageKey(keyCode)) { + if (event.getRepeatCount() == 0) { + showPage(page == PAGE_MINIMUM ? PAGE_SETTINGS : page - 1); + } + return true; + } + if (isNextPageKey(keyCode)) { + if (event.getRepeatCount() == 0) { + showPage(page == PAGE_SETTINGS ? PAGE_MINIMUM : page + 1); + } + return true; + } + if (isActivateKey(keyCode)) { + if (event.getRepeatCount() == 0) { + launchPageAction(); + } + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (RadioPttKeyManager.isConfiguredPttKey(keyCode, settings) + && !RadioPttKeyManager.isMediaStyleKey(keyCode)) { + signalPttReleased(); + return true; + } + if (RadioKeyActionPolicy.isIdentityToggleEvent( + RadioDeviceProfile.detectCurrent(), event)) { + finishIdentityToggle(keyCode, event); + return true; + } + if (isPreviousPageKey(keyCode) || isNextPageKey(keyCode) + || isActivateKey(keyCode)) { + return true; + } + return super.onKeyUp(keyCode, event); + } + + @Override + protected void onDestroy() { + identityHandler.removeCallbacks(identityToggleAction); + if (pttRecoveryTone != null) { + pttRecoveryTone.release(); + pttRecoveryTone = null; + } + super.onDestroy(); + } + + @Override + public void onBackPressed() { + if (page != PAGE_MINIMUM) { + showPage(PAGE_MINIMUM); + } + } + + private void showPage(int requestedPage) { + page = Math.max(PAGE_MINIMUM, Math.min(PAGE_SETTINGS, requestedPage)); + root.removeAllViews(); + + LinearLayout pageView = new LinearLayout(this); + pageView.setOrientation(LinearLayout.VERTICAL); + pageView.setGravity(android.view.Gravity.CENTER_HORIZONTAL); + pageView.setPadding(dp(4), dp(4), dp(4), dp(2)); + pageView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + launchPageAction(); + } + }); + + ImageView icon = new ImageView(this); + icon.setImageDrawable(getPageIcon()); + icon.setScaleType(ImageView.ScaleType.FIT_CENTER); + icon.setContentDescription(getPageLabel()); + pageView.addView(icon, new LinearLayout.LayoutParams(-1, 0, 1.0f)); + + TextView label = new TextView(this); + label.setText(getPageLabel()); + label.setTextColor(Color.WHITE); + label.setTextSize(16); + label.setGravity(android.view.Gravity.CENTER); + pageView.addView(label, new LinearLayout.LayoutParams(-1, dp(24))); + + pageIndicator = new TextView(this); + pageIndicator.setText((page + 1) + " / 2"); + pageIndicator.setTextColor(Color.rgb(180, 195, 205)); + pageIndicator.setTextSize(10); + pageIndicator.setGravity(android.view.Gravity.CENTER); + pageView.addView(pageIndicator, new LinearLayout.LayoutParams(-1, dp(18))); + + root.addView(pageView, new FrameLayout.LayoutParams(-1, -1)); + identityOverlayView = new TextView(this); + identityOverlayView.setText(new DeviceIdentityManager( + android.preference.PreferenceManager.getDefaultSharedPreferences(this)) + .getOrCreateDeviceId()); + identityOverlayView.setTextColor(Color.WHITE); + identityOverlayView.setTextSize(30); + identityOverlayView.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + identityOverlayView.setGravity(android.view.Gravity.CENTER); + identityOverlayView.setBackgroundColor(Color.rgb(7, 25, 43)); + identityOverlayView.setVisibility(identityOverlayVisible ? View.VISIBLE : View.GONE); + root.addView(identityOverlayView, new FrameLayout.LayoutParams(-1, -1)); + } + + private Drawable getPageIcon() { + try { + if (page == PAGE_MINIMUM) { + ApplicationInfo info = getPackageManager().getApplicationInfo(getPackageName(), 0); + return info.loadIcon(getPackageManager()); + } + if (page == PAGE_SETTINGS) { + return getPackageManager().getApplicationIcon("com.android.settings"); + } + } catch (PackageManager.NameNotFoundException ignored) { + // Fall through to a platform icon when an OEM removes an optional package. + } + return getDrawable(android.R.drawable.ic_menu_preferences); + } + + private String getPageLabel() { + if (page == PAGE_MINIMUM) { + return getString(R.string.app_name); + } + if (page == PAGE_SETTINGS) { + return "Settings"; + } + return "Settings"; + } + + private void launchPageAction() { + if (page == PAGE_MINIMUM) { + startActivity(new Intent(this, RadioShellActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } else if (page == PAGE_SETTINGS) { + try { + startActivity(new Intent(Settings.ACTION_SETTINGS)); + } catch (RuntimeException ignored) { + showPage(PAGE_MINIMUM); + } + } + } + + private void launchRadioForPttRecovery() { + RadioPttRecoveryGuard.requireRelease(); + signalPttReleaseRequired(); + startActivity(new Intent(this, RadioShellActivity.class) + .putExtra(RadioShellActivity.EXTRA_CONNECT_ON_PTT, true) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } + + private void signalPttReleaseRequired() { + sendPttSafetyAction(MumlaService.ACTION_RADIO_REQUIRE_PTT_RELEASE); + } + + private void signalPttReleased() { + RadioPttRecoveryGuard.noteRelease(); + sendPttSafetyAction(MumlaService.ACTION_RADIO_PTT_RELEASED); + } + + private void sendPttSafetyAction(String action) { + try { + startService(new Intent(this, MumlaService.class).setAction(action)); + } catch (RuntimeException ignored) { + // RadioShell also applies the release lock once its service binding completes. + } + } + + private void playPttRecoveryAlert() { + try { + if (pttRecoveryTone == null) { + pttRecoveryTone = new ToneGenerator(AudioManager.STREAM_MUSIC, 90); + } + pttRecoveryTone.startTone(ToneGenerator.TONE_SUP_ERROR, 700); + } catch (RuntimeException ignored) { + // The full-screen recovery state is still shown when an OEM audio path is unavailable. + } + } + + private boolean isPreviousPageKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_LEFT; + } + + private boolean isNextPageKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT; + } + + private boolean isActivateKey(int keyCode) { + if (RadioDeviceProfile.T99.equals(RadioDeviceProfile.detectCurrent())) { + return keyCode == KeyEvent.KEYCODE_MENU + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER; + } + return keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_ENTER + || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + || keyCode == KeyEvent.KEYCODE_BUTTON_SELECT + || keyCode == KeyEvent.KEYCODE_CALL; + } + + private void beginIdentityToggle(int keyCode, KeyEvent event) { + if (event.getRepeatCount() != 0 || pendingIdentityKey == keyCode) { + return; + } + pendingIdentityKey = keyCode; + pendingIdentityStartedAt = event.getEventTime(); + identityHoldCompleted = false; + identityHandler.postDelayed(identityToggleAction, RadioKeyActionPolicy.IDENTITY_HOLD_MS); + } + + private void finishIdentityToggle(int keyCode, KeyEvent event) { + if (pendingIdentityKey != keyCode) { + return; + } + boolean completed = identityHoldCompleted; + if (!completed && RadioKeyActionPolicy.heldLongEnough(pendingIdentityStartedAt, + event.getEventTime(), RadioKeyActionPolicy.IDENTITY_HOLD_MS)) { + setIdentityOverlayVisible(!identityOverlayVisible); + completed = true; + } + pendingIdentityKey = KeyEvent.KEYCODE_UNKNOWN; + pendingIdentityStartedAt = -1L; + identityHoldCompleted = false; + identityHandler.removeCallbacks(identityToggleAction); + if (!completed && RadioDeviceProfile.T99.equals(RadioDeviceProfile.detectCurrent())) { + launchPageAction(); + } + } + + private void setIdentityOverlayVisible(boolean visible) { + identityOverlayVisible = visible; + if (identityOverlayView != null) { + identityOverlayView.setVisibility(visible ? View.VISIBLE : View.GONE); + } + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioCertificateManager.java b/app/src/main/java/se/lublin/mumla/radio/RadioCertificateManager.java new file mode 100644 index 00000000..46ce198c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioCertificateManager.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; + +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.DatabaseCertificate; +import se.lublin.mumla.preference.MumlaCertificateGenerateTask; + +/** Ensures the radio path has client identity material without showing a first-run dialog. */ +public final class RadioCertificateManager { + private RadioCertificateManager() { + } + + public interface Callback { + void onComplete(boolean available); + } + + public static void ensureCertificate(Context context, Callback callback) { + Context applicationContext = context.getApplicationContext(); + Settings settings = Settings.getInstance(applicationContext); + if (settings.isUsingCertificate()) { + callback.onComplete(true); + return; + } + + new MumlaCertificateGenerateTask(applicationContext, false) { + @Override + protected void onPostExecute(DatabaseCertificate result) { + super.onPostExecute(result); + if (result != null) { + settings.setDefaultCertificateId(result.getId()); + settings.setFirstRun(false); + } + callback.onComplete(result != null); + } + }.execute(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConfigActivationPolicy.java b/app/src/main/java/se/lublin/mumla/radio/RadioConfigActivationPolicy.java new file mode 100644 index 00000000..2f574a89 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConfigActivationPolicy.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +/** Pure idle gate for applying a staged radio configuration. */ +public final class RadioConfigActivationPolicy { + private RadioConfigActivationPolicy() { + } + + public static boolean canTrial(boolean serviceAvailable, boolean connectionTransitioning, + boolean transmitting, boolean receiving) { + return serviceAvailable && !connectionTransitioning && !transmitting && !receiving; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java b/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java new file mode 100644 index 00000000..ac8794ee --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java @@ -0,0 +1,692 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +import se.lublin.mumla.radio.tracking.AprsObjectName; + +/** + * Loads the radio configuration from the embedded safe default and private control plane. + * Network refresh must be called from a worker thread. Device credentials are read from the + * app-private credential store, applied only to the private device request, and never logged. + */ +public final class RadioConfigRepository { + public static final int SCHEMA_VERSION = 3; + public static final int MAX_CONFIG_BYTES = 262144; + public static final int MAX_PTT_SECONDS = 120; + public static final String DEVICE_CONFIG_BASE_URL = + "https://minimum.vra.or.th/api/device-config/"; + + private static final int CONNECT_TIMEOUT_MS = 8000; + private static final int READ_TIMEOUT_MS = 8000; + private static final String ASSET_DEFAULT = "radio/default.json"; + private static final String ACTIVE_FILE = "active-config.json"; + private static final String PREVIOUS_FILE = "previous-config.json"; + private static final String PENDING_FILE = "pending-config.json"; + private static final String TEMP_FILE = "downloaded-config.tmp"; + private static final String PROVISIONED_TEMP_FILE = "provisioned-config.tmp"; + private static final String ROLLBACK_TEMP_FILE = "rollback-config.tmp"; + private static final String PREVIOUS_BACKUP_FILE = "previous-config.backup"; + private static final Object CACHE_LOCK = new Object(); + + private final Context context; + private final DeviceConfigCredentialStore credentialStore; + private volatile SSLSocketFactory configSslSocketFactory; + + public RadioConfigRepository(Context context) { + this(context, new DeviceConfigCredentialStore(context)); + } + + RadioConfigRepository(Context context, DeviceConfigCredentialStore credentialStore) { + if (context == null) { + throw new IllegalArgumentException("context must not be null"); + } + if (credentialStore == null) { + throw new IllegalArgumentException("credential store must not be null"); + } + this.context = context.getApplicationContext(); + this.credentialStore = credentialStore; + } + + /** Returns the validated active cache, or the embedded default if the cache is absent/bad. */ + public JSONObject loadActiveOrDefault() throws IOException, JSONException { + synchronized (CACHE_LOCK) { + File directory = cacheDirectory(); + File active = new File(directory, ACTIVE_FILE); + if (active.isFile()) { + try { + JSONObject cached = readJson(active); + validateCompleteConfig(cached, null); + return cached; + } catch (JSONException | IOException ignored) { + // Try the previous Last Known Good before the embedded fallback. + } + } + File previous = new File(directory, PREVIOUS_FILE); + if (previous.isFile()) { + try { + JSONObject recovered = readJson(previous); + validateCompleteConfig(recovered, null); + rollbackFiles(directory); + return recovered; + } catch (JSONException | IOException ignored) { + // A bad cache must never prevent the radio client from starting. + } + } + } + JSONObject fallback = readEmbeddedDefault(); + validateCompleteConfig(fallback, null); + return fallback; + } + + /** + * Fetches and merges default, model and optional device configuration. The result is staged as + * pending and cannot replace the Last Known Good active config until the radio proves it works. + */ + public JSONObject refresh(String deviceId, String modelProfile) + throws IOException, JSONException { + if (!DeviceIdentityManager.isValidDeviceId(deviceId)) { + throw new IllegalArgumentException("invalid device id"); + } + if (!isSafePathPart(modelProfile)) { + throw new IllegalArgumentException("invalid model profile"); + } + final String authorization; + try { + authorization = credentialStore.getAuthorizationHeader(); + } catch (IllegalArgumentException exception) { + throw new DeviceConfigUnavailableException(); + } + if (authorization == null) { + throw new DeviceConfigUnavailableException(); + } + JSONObject merged = fetchDeviceConfig(deviceId, authorization); + validateCompleteConfig(merged, deviceId); + synchronized (CACHE_LOCK) { + File active = new File(cacheDirectory(), ACTIVE_FILE); + if (active.isFile()) { + JSONObject current = null; + try { + current = readJson(active); + validateCompleteConfig(current, null); + } catch (IOException | JSONException ignored) { + // An unreadable active cache is not a valid equality baseline. + } + if (current != null) { + rejectDowngrade(merged, current); + if (current.optInt("configVersion", -1) + == merged.optInt("configVersion", -2)) { + if (!current.toString().equals(merged.toString())) { + throw new JSONException( + "config content changed without version advance"); + } + discardPendingLocked(); + return merged; + } + } + } + writePendingLocked(merged); + } + return merged; + } + + /** Returns the validated candidate waiting for an idle radio trial, or null when absent. */ + public JSONObject loadPending() throws IOException, JSONException { + synchronized (CACHE_LOCK) { + File pending = new File(cacheDirectory(), PENDING_FILE); + if (!pending.isFile()) { + return null; + } + JSONObject candidate = readJson(pending); + validateCompleteConfig(candidate, null); + return candidate; + } + } + + public boolean hasPending() { + synchronized (CACHE_LOCK) { + return new File(cacheDirectory(), PENDING_FILE).isFile(); + } + } + + /** Commits a candidate only after the radio connected and joined its configured room. */ + public JSONObject commitPending() throws IOException, JSONException { + synchronized (CACHE_LOCK) { + File directory = cacheDirectory(); + File pending = new File(directory, PENDING_FILE); + if (!pending.isFile()) { + throw new IOException("no pending config to commit"); + } + JSONObject candidate = readJson(pending); + validateCompleteConfig(candidate, null); + File active = new File(directory, ACTIVE_FILE); + if (active.isFile()) { + try { + JSONObject current = readJson(active); + validateCompleteConfig(current, null); + rejectDowngrade(candidate, current); + } catch (JSONException error) { + throw error; + } catch (IOException ignored) { + // An unreadable active cache is not a valid downgrade baseline. + } + } + promotePendingFiles(directory); + return candidate; + } + } + + /** Explicitly restores the previous Last Known Good config and returns it. */ + public JSONObject rollbackToPrevious() throws IOException, JSONException { + synchronized (CACHE_LOCK) { + File directory = cacheDirectory(); + File previous = new File(directory, PREVIOUS_FILE); + if (!previous.isFile()) { + throw new IOException("no previous config to roll back"); + } + JSONObject restored = readJson(previous); + validateCompleteConfig(restored, null); + rollbackFiles(directory); + return restored; + } + } + + public void discardPending() throws IOException { + synchronized (CACHE_LOCK) { + discardPendingLocked(); + } + } + + /** Installs or rotates the per-device bearer credential without changing radio config files. */ + public void setDeviceConfigCredential(String credential) throws IOException { + credentialStore.setCredential(credential); + } + + /** Removes the per-device bearer credential without changing radio config files. */ + public void clearDeviceConfigCredential() throws IOException { + credentialStore.clearCredential(); + } + + /** Installs an explicitly provisioned Last Known Good config from the protected ADB path. */ + public JSONObject installProvisionedActive(InputStream input, String expectedDeviceId) + throws IOException, JSONException { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + JSONObject provisioned = new JSONObject(readLimited(input)); + validateCompleteConfig(provisioned, expectedDeviceId); + synchronized (CACHE_LOCK) { + File directory = cacheDirectory(); + File active = new File(directory, ACTIVE_FILE); + if (active.isFile()) { + JSONObject current = readJson(active); + validateCompleteConfig(current, null); + rejectDowngrade(provisioned, current); + if (current.optInt("configVersion", -1) + == provisioned.optInt("configVersion", -2) + && !current.toString().equals(provisioned.toString())) { + throw new JSONException("config content changed without version advance"); + } + } + installProvisionedFiles(directory, + provisioned.toString().getBytes(StandardCharsets.UTF_8)); + } + return provisioned; + } + + /** Updates only the active APRS Object label without exporting the private config. */ + public JSONObject updateActiveAprsObjectName(String expectedDeviceId, String configuredName) + throws IOException, JSONException { + synchronized (CACHE_LOCK) { + File directory = cacheDirectory(); + File active = new File(directory, ACTIVE_FILE); + if (!active.isFile()) { + throw new IOException("active radio config is missing"); + } + JSONObject current = readJson(active); + JSONObject updated = withAprsObjectName(current, expectedDeviceId, configuredName); + if (!current.toString().equals(updated.toString())) { + installProvisionedFiles(directory, + updated.toString().getBytes(StandardCharsets.UTF_8)); + } + return updated; + } + } + + static JSONObject withAprsObjectName(JSONObject current, String expectedDeviceId, + String configuredName) throws JSONException { + validateConfig(current, expectedDeviceId); + String normalized; + try { + normalized = AprsObjectName.normalizeConfiguredName(configuredName); + } catch (IllegalArgumentException exception) { + throw new JSONException(exception.getMessage()); + } + JSONObject updated = new JSONObject(current.toString()); + JSONObject tracking = updated.optJSONObject("tracking"); + JSONObject aprs = tracking == null ? null : tracking.optJSONObject("aprs"); + if (aprs == null) { + throw new JSONException("APRS config is missing"); + } + if (normalized.equals(aprs.optString("objectName", ""))) { + return updated; + } + int version = updated.optInt("configVersion", 0); + if (version < 1 || version == Integer.MAX_VALUE) { + throw new JSONException("config version cannot be advanced"); + } + aprs.put("objectName", normalized); + updated.put("configVersion", version + 1); + validateConfig(updated, expectedDeviceId); + return updated; + } + + /** Rejects a candidate that would replace a newer Last Known Good configuration. */ + public static void rejectDowngrade(JSONObject candidate, JSONObject active) + throws JSONException { + int candidateVersion = candidate == null ? -1 : candidate.optInt("configVersion", -1); + int activeVersion = active == null ? -1 : active.optInt("configVersion", -1); + if (candidateVersion < activeVersion) { + throw new JSONException("config downgrade rejected"); + } + } + + /** Deep-merges JSON objects; arrays and scalar values from overlay replace the base value. */ + public static JSONObject merge(JSONObject base, JSONObject overlay) throws JSONException { + JSONObject result = new JSONObject(base.toString()); + JSONArray names = overlay.names(); + if (names == null) { + return result; + } + for (int i = 0; i < names.length(); i++) { + String name = names.getString(i); + Object overlayValue = overlay.get(name); + Object baseValue = result.opt(name); + if (overlayValue instanceof JSONObject && baseValue instanceof JSONObject) { + result.put(name, merge((JSONObject) baseValue, (JSONObject) overlayValue)); + } else { + result.put(name, overlayValue); + } + } + return result; + } + + /** Validates invariants that protect startup and the PTT safety contract. */ + public static void validateConfig(JSONObject config, String expectedDeviceId) + throws JSONException { + validateOverlay(config, expectedDeviceId); + JSONObject radio = config.optJSONObject("radio"); + JSONObject ptt = config.optJSONObject("ptt"); + if (radio == null || radio.optString("defaultChannel", "").isEmpty()) { + throw new JSONException("incomplete radio selection config"); + } + if (ptt == null || ptt.optInt("maximumTxSeconds", 0) < 1 + || ptt.optInt("maximumTxSeconds", MAX_PTT_SECONDS) > MAX_PTT_SECONDS + || !ptt.optBoolean("releaseOnNetworkLoss", false)) { + throw new JSONException("unsafe PTT config"); + } + JSONObject connections = config.optJSONObject("connections"); + JSONArray channels = config.optJSONArray("channels"); + JSONObject hardware = config.optJSONObject("hardware"); + if (connections == null || connections.length() == 0 + || channels == null || channels.length() == 0 || hardware == null + || hardware.optString("profile", "").isEmpty()) { + throw new JSONException("incomplete radio config"); + } + } + + /** + * Validates repository invariants and the complete live radio shape before persistence. + * Overlay validation intentionally omits fields supplied by a base config; this additionally + * runs the parser's channel, connection, access-policy and path checks. + */ + static void validateCompleteConfig(JSONObject config, String expectedDeviceId) + throws JSONException { + validateConfig(config, expectedDeviceId); + RadioConnectionConfig.fromJson(config); + } + + /** Validates a complete config overlay without requiring fields supplied by the base config. */ + public static void validateOverlay(JSONObject config, String expectedDeviceId) + throws JSONException { + if (config == null || config.optInt("schemaVersion", -1) != SCHEMA_VERSION) { + throw new JSONException("unsupported config schema"); + } + if (config.optInt("configVersion", 0) < 1) { + throw new JSONException("invalid config version"); + } + String configDeviceId = config.optString("deviceId", "*"); + if (!"*".equals(configDeviceId) + && !DeviceIdentityManager.isValidDeviceId(configDeviceId)) { + throw new JSONException("invalid config device id"); + } + if (expectedDeviceId != null && !"*".equals(configDeviceId) + && !expectedDeviceId.equals(configDeviceId)) { + throw new JSONException("config is for another device"); + } + JSONObject connections = config.optJSONObject("connections"); + if (connections != null) { + java.util.Iterator ids = connections.keys(); + while (ids.hasNext()) { + JSONObject connection = connections.optJSONObject(ids.next()); + if (connection == null) { + throw new JSONException("invalid connection overlay"); + } + if (connection.has("username")) { + if (!(connection.opt("username") instanceof String)) { + throw new JSONException("invalid Mumble username type"); + } + RadioConnectionConfig.normalizeMumbleUsername( + connection.optString("username", "")); + } + if (connection.has("port")) { + int port = connection.optInt("port", -1); + if (port < 1 || port > 65535) { + throw new JSONException("invalid Mumble port"); + } + } + if (connection.has("serverCertificateSha256")) { + RadioConnectionConfig.normalizeFingerprint( + connection.optString("serverCertificateSha256", "")); + } + if (connection.has("autoTrustServerCertificate") + && !(connection.opt("autoTrustServerCertificate") instanceof Boolean)) { + throw new JSONException("invalid automatic certificate trust policy"); + } + if (connection.has("password") + && !(connection.opt("password") instanceof String)) { + throw new JSONException("invalid server password type"); + } + } + } + JSONObject radio = config.optJSONObject("radio"); + if (radio != null) { + if (radio.has("autoConnect") && !(radio.opt("autoConnect") instanceof Boolean)) { + throw new JSONException("invalid automatic connection policy"); + } + if (radio.has("autoReconnect") + && !(radio.opt("autoReconnect") instanceof Boolean)) { + throw new JSONException("invalid automatic reconnection policy"); + } + } + JSONObject ptt = config.optJSONObject("ptt"); + if (ptt != null) { + int maximum = ptt.optInt("maximumTxSeconds", MAX_PTT_SECONDS); + if (maximum < 1 || maximum > MAX_PTT_SECONDS + || (ptt.has("releaseOnNetworkLoss") + && !ptt.optBoolean("releaseOnNetworkLoss", false))) { + throw new JSONException("unsafe PTT overlay"); + } + } + JSONArray channels = config.optJSONArray("channels"); + if (channels != null && channels.length() == 0) { + throw new JSONException("empty channel overlay"); + } + JSONObject hardware = config.optJSONObject("hardware"); + if (hardware != null && hardware.has("profile") + && hardware.optString("profile", "").isEmpty()) { + throw new JSONException("empty hardware profile"); + } + if (config.has("tracking")) { + JSONObject tracking = config.optJSONObject("tracking"); + if (tracking == null) { + throw new JSONException("invalid tracking overlay"); + } + if (tracking.has("aprs")) { + JSONObject aprs = tracking.optJSONObject("aprs"); + if (aprs == null) { + throw new JSONException("invalid APRS overlay"); + } + if (aprs.has("objectName")) { + if (!(aprs.opt("objectName") instanceof String)) { + throw new JSONException("invalid APRS Object name type"); + } + try { + AprsObjectName.normalizeConfiguredName( + aprs.optString("objectName", "")); + } catch (IllegalArgumentException exception) { + throw new JSONException(exception.getMessage()); + } + } + } + } + } + + private JSONObject readEmbeddedDefault() throws IOException, JSONException { + try (InputStream input = context.getAssets().open(ASSET_DEFAULT)) { + return new JSONObject(readLimited(input)); + } + } + + private JSONObject fetchDeviceConfig(String deviceId, String authorization) + throws IOException, JSONException { + HttpURLConnection connection = openConnection( + new URL(deviceConfigUrl(deviceId)), authorization); + try { + int status = connection.getResponseCode(); + if (status == HttpURLConnection.HTTP_UNAUTHORIZED + || status == HttpURLConnection.HTTP_NOT_FOUND) { + throw new DeviceConfigUnavailableException(); + } + if (status < 200 || status >= 300) { + throw new IOException("device config request failed"); + } + return new JSONObject(readLimited(connection.getInputStream())); + } finally { + connection.disconnect(); + } + } + + static String deviceConfigUrl(String deviceId) { + if (!DeviceIdentityManager.isValidDeviceId(deviceId)) { + throw new IllegalArgumentException("invalid device id"); + } + return DEVICE_CONFIG_BASE_URL + deviceId; + } + + private HttpURLConnection openConnection(URL url, String authorization) + throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + if (connection instanceof HttpsURLConnection) { + ((HttpsURLConnection) connection).setSSLSocketFactory(configSslSocketFactory()); + } + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestMethod("GET"); + connection.setInstanceFollowRedirects(false); + if (authorization != null) { + connection.setRequestProperty("Authorization", authorization); + } + return connection; + } + + private SSLSocketFactory configSslSocketFactory() throws IOException { + SSLSocketFactory existing = configSslSocketFactory; + if (existing != null) { + return existing; + } + synchronized (this) { + if (configSslSocketFactory == null) { + configSslSocketFactory = ConfigTlsSocketFactory.create(context); + } + return configSslSocketFactory; + } + } + + private File cacheDirectory() { + File directory = new File(context.getFilesDir(), "radio-config"); + if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) { + throw new IllegalStateException("cannot create radio config cache"); + } + return directory; + } + + private void writePendingLocked(JSONObject config) throws IOException { + File directory = cacheDirectory(); + File pending = new File(directory, PENDING_FILE); + File temporary = new File(directory, TEMP_FILE); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(config.toString().getBytes(StandardCharsets.UTF_8)); + output.getFD().sync(); + } + deleteIfPresent(pending, "cannot replace pending config"); + if (!temporary.renameTo(pending)) { + throw new IOException("cannot stage downloaded config"); + } + } + + private void discardPendingLocked() throws IOException { + File directory = cacheDirectory(); + deleteIfPresent(new File(directory, PENDING_FILE), "cannot discard pending config"); + deleteIfPresent(new File(directory, TEMP_FILE), "cannot discard temporary config"); + } + + static void promotePendingFiles(File directory) throws IOException { + File active = new File(directory, ACTIVE_FILE); + File previous = new File(directory, PREVIOUS_FILE); + File pending = new File(directory, PENDING_FILE); + File previousBackup = new File(directory, PREVIOUS_BACKUP_FILE); + if (!pending.isFile()) { + throw new IOException("pending config is missing"); + } + deleteIfPresent(previousBackup, "cannot prepare previous config backup"); + boolean previousBackedUp = false; + if (previous.isFile()) { + if (!previous.renameTo(previousBackup)) { + throw new IOException("cannot preserve previous config backup"); + } + previousBackedUp = true; + } + boolean activeMoved = false; + if (active.isFile()) { + if (!active.renameTo(previous)) { + if (previousBackedUp && !previousBackup.renameTo(previous)) { + throw new IOException("cannot preserve active or restore previous config"); + } + throw new IOException("cannot preserve active config"); + } + activeMoved = true; + } + if (!pending.renameTo(active)) { + if (activeMoved && !previous.renameTo(active)) { + throw new IOException("cannot activate pending or restore active config"); + } + if (previousBackedUp && !previousBackup.renameTo(previous)) { + throw new IOException("cannot activate pending or restore previous config"); + } + throw new IOException("cannot activate pending config"); + } + deleteIfPresent(previousBackup, "cannot remove previous config backup"); + } + + static void installProvisionedFiles(File directory, byte[] config) throws IOException { + if (directory == null || config == null || config.length == 0 + || config.length > MAX_CONFIG_BYTES) { + throw new IOException("invalid provisioned config"); + } + if (!directory.isDirectory() && !directory.mkdirs() && !directory.isDirectory()) { + throw new IOException("cannot create radio config directory"); + } + File temporary = new File(directory, PROVISIONED_TEMP_FILE); + File pending = new File(directory, PENDING_FILE); + deleteIfPresent(temporary, "cannot replace provisioned temporary config"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(config); + output.getFD().sync(); + } + deleteIfPresent(pending, "cannot replace pending config during provisioning"); + if (!temporary.renameTo(pending)) { + throw new IOException("cannot stage provisioned config"); + } + promotePendingFiles(directory); + } + + static void rollbackFiles(File directory) throws IOException { + File active = new File(directory, ACTIVE_FILE); + File previous = new File(directory, PREVIOUS_FILE); + File rollbackTemporary = new File(directory, ROLLBACK_TEMP_FILE); + if (!previous.isFile()) { + throw new IOException("previous config is missing"); + } + deleteIfPresent(rollbackTemporary, "cannot clear rollback temporary config"); + boolean activeMoved = false; + if (active.isFile()) { + if (!active.renameTo(rollbackTemporary)) { + throw new IOException("cannot preserve current config during rollback"); + } + activeMoved = true; + } + if (!previous.renameTo(active)) { + if (activeMoved && !rollbackTemporary.renameTo(active)) { + throw new IOException("cannot roll back or restore current config"); + } + throw new IOException("cannot restore previous config"); + } + deleteIfPresent(rollbackTemporary, "cannot remove rolled-back config"); + } + + private static void deleteIfPresent(File file, String error) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException(error); + } + } + + private static JSONObject readJson(File file) throws IOException, JSONException { + try (InputStream input = new FileInputStream(file)) { + return new JSONObject(readLimited(input)); + } + } + + private static String readLimited(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int total = 0; + int read; + while ((read = input.read(buffer)) != -1) { + total += read; + if (total > MAX_CONFIG_BYTES) { + throw new IOException("config exceeds maximum size"); + } + output.write(buffer, 0, read); + } + return output.toString(StandardCharsets.UTF_8.name()); + } + + private static boolean isSafePathPart(String value) { + return value != null && value.matches("[a-z0-9-]{1,64}"); + } + + /** Generic hold used when a private device config is not authorized or does not exist. */ + public static final class DeviceConfigUnavailableException extends IOException { + private static final long serialVersionUID = 1L; + + public DeviceConfigUnavailableException() { + super("device config unavailable"); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java b/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java new file mode 100644 index 00000000..97a9f5af --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import org.json.JSONException; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Schedules quiet config refreshes and stages validated candidates for idle radio activation. */ +public final class RadioConfigUpdater { + public static final String ACTION_CONFIG_PENDING = + "se.lublin.mumla.action.RADIO_CONFIG_PENDING"; + + private static final String TAG = RadioConfigUpdater.class.getName(); + private static final String PREF_LAST_SUCCESS = "radio_config_last_success_ms"; + private static final long REFRESH_INTERVAL_MS = 6L * 60L * 60L * 1000L; + private static final AtomicBoolean REFRESH_IN_FLIGHT = new AtomicBoolean(false); + private static final Object NETWORK_MONITOR_LOCK = new Object(); + + private static boolean networkMonitorRegistered; + private static boolean lastNetworkConnected; + + private RadioConfigUpdater() { + } + + /** Starts the six-hour refresh and a process-lifetime network-return trigger. */ + public static void start(Context context) { + Context applicationContext = context.getApplicationContext(); + registerNetworkReturnMonitor(applicationContext); + schedule(applicationContext, false); + } + + public static void schedule(Context context) { + schedule(context, false); + } + + /** Forces a refresh after a protected device credential is installed or rotated. */ + static void scheduleNow(Context context) { + schedule(context, true); + } + + static boolean shouldRefresh(long now, long lastSuccess, boolean force) { + return force || lastSuccess <= 0L || now - lastSuccess >= REFRESH_INTERVAL_MS; + } + + private static void schedule(Context context, boolean force) { + Context applicationContext = context.getApplicationContext(); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext); + long now = System.currentTimeMillis(); + long lastSuccess = preferences.getLong(PREF_LAST_SUCCESS, 0L); + if (!shouldRefresh(now, lastSuccess, force) + || !REFRESH_IN_FLIGHT.compareAndSet(false, true)) { + return; + } + + new Thread(() -> { + try { + String deviceId = new DeviceIdentityManager(preferences).getOrCreateDeviceId(); + String modelProfile = RadioDeviceProfile.detectCurrent(); + RadioConfigRepository repository = new RadioConfigRepository(applicationContext); + repository.refresh(deviceId, modelProfile); + preferences.edit().putLong(PREF_LAST_SUCCESS, + System.currentTimeMillis()).apply(); + if (repository.hasPending()) { + Intent available = new Intent(ACTION_CONFIG_PENDING) + .setPackage(applicationContext.getPackageName()); + applicationContext.sendBroadcast(available); + } + } catch (RadioConfigRepository.DeviceConfigUnavailableException exception) { + // A missing, revoked, or unknown device credential is deliberately indistinguishable + // to logs and leaves the current Last Known Good config untouched. + Log.w(TAG, "Radio config refresh skipped; device configuration unavailable"); + } catch (IOException | JSONException | RuntimeException exception) { + // Remote config is optional. The repository's embedded/cache fallback remains the + // source of truth when the network is unavailable or the response is unsafe. + Log.w(TAG, "Radio config refresh skipped; using local configuration (" + + exception.getClass().getSimpleName() + ")"); + } finally { + REFRESH_IN_FLIGHT.set(false); + } + }, "minimum-radio-config").start(); + } + + private static void registerNetworkReturnMonitor(Context applicationContext) { + synchronized (NETWORK_MONITOR_LOCK) { + if (networkMonitorRegistered) { + return; + } + lastNetworkConnected = isNetworkConnected(applicationContext); + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + boolean connected = isNetworkConnected(applicationContext); + boolean returned; + synchronized (NETWORK_MONITOR_LOCK) { + returned = connected && !lastNetworkConnected; + lastNetworkConnected = connected; + } + if (returned) { + schedule(applicationContext, true); + } + } + }; + applicationContext.registerReceiver(receiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + networkMonitorRegistered = true; + } + } + + static boolean isNetworkConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo network = manager == null ? null : manager.getActiveNetworkInfo(); + return network != null && network.isConnected(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java b/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java new file mode 100644 index 00000000..4a5cd8a6 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConnectionConfig.java @@ -0,0 +1,479 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import se.lublin.humla.model.Server; + +/** Immutable, validated subset of radio config used by the live connection/UI path. */ +public final class RadioConnectionConfig { + private static final int MAX_CHANNELS = 16; + private static final int MAX_CHANNEL_ALIAS_LENGTH = 32; + + private final int configVersion; + private final String serviceName; + private final boolean autoConnect; + private final boolean autoReconnect; + private final List channels; + private final int defaultChannelIndex; + + private RadioConnectionConfig(int configVersion, String serviceName, boolean autoConnect, + boolean autoReconnect, List channels, + int defaultChannelIndex) { + this.configVersion = configVersion; + this.serviceName = serviceName; + this.autoConnect = autoConnect; + this.autoReconnect = autoReconnect; + this.channels = Collections.unmodifiableList(new ArrayList<>(channels)); + this.defaultChannelIndex = defaultChannelIndex; + } + + public static RadioConnectionConfig fromJson(JSONObject config) throws JSONException { + RadioConfigRepository.validateConfig(config, null); + + JSONObject service = config.optJSONObject("service"); + String serviceName = service == null + ? "Minimum" + : requireNonBlank(service.optString("name", "Minimum"), "service name"); + JSONObject radio = config.getJSONObject("radio"); + String defaultChannelId = requireIdentifier( + radio.optString("defaultChannel", ""), "default channel"); + + Map connections = parseConnections( + config.getJSONObject("connections"), serviceName); + JSONArray channelArray = config.getJSONArray("channels"); + if (channelArray.length() > MAX_CHANNELS) { + throw new JSONException("too many radio channels"); + } + + List channels = new ArrayList<>(); + Set channelIds = new HashSet<>(); + int defaultChannelIndex = -1; + for (int index = 0; index < channelArray.length(); index++) { + JSONObject channelJson = channelArray.optJSONObject(index); + if (channelJson == null) { + throw new JSONException("invalid channel entry"); + } + String id = requireIdentifier(channelJson.optString("id", ""), "channel id"); + if (!channelIds.add(id)) { + throw new JSONException("duplicate channel id"); + } + String connectionId = requireIdentifier( + channelJson.optString("connectionId", ""), "connection id"); + Connection connection = connections.get(connectionId); + if (connection == null) { + throw new JSONException("channel references missing connection"); + } + List accessTokens = validateAndResolveAccess(channelJson); + String label = requireNonBlank(channelJson.optString("label", ""), "channel label"); + String alias = channelJson.has("alias") + ? normalizeChannelAlias(channelJson.opt("alias"), "channel alias") + : label; + Channel channel = new Channel( + id, + label, + alias, + normalizePath(channelJson.optString("path", "")), + channelJson.optString("presetKey", ""), + connection, + accessTokens); + if (id.equals(defaultChannelId)) { + defaultChannelIndex = channels.size(); + } + channels.add(channel); + } + if (defaultChannelIndex < 0) { + throw new JSONException("default channel is missing"); + } + + return new RadioConnectionConfig( + config.getInt("configVersion"), + serviceName, + radio.optBoolean("autoConnect", false), + radio.optBoolean("autoReconnect", true), + channels, + defaultChannelIndex); + } + + private static List validateAndResolveAccess(JSONObject channel) throws JSONException { + JSONObject access = channel.optJSONObject("access"); + if (access == null || !(access.opt("mode") instanceof String)) { + throw new JSONException("invalid channel access policy"); + } + String mode = access.optString("mode", ""); + if ("none".equals(mode)) { + return Collections.emptyList(); + } + if ("protected".equals(mode)) { + if (!(access.opt("tokenRef") instanceof String)) { + throw new JSONException("invalid protected token reference"); + } + requireNonBlank(access.optString("tokenRef", ""), "protected token reference"); + return Collections.emptyList(); + } + if (!"public".equals(mode)) { + throw new JSONException("unknown channel access mode"); + } + if (access.has("token") && !(access.opt("token") instanceof String)) { + throw new JSONException("invalid channel access token"); + } + if (access.has("tokens")) { + JSONArray values = access.optJSONArray("tokens"); + if (values == null) { + throw new JSONException("invalid channel access token list"); + } + for (int index = 0; index < values.length(); index++) { + if (!(values.opt(index) instanceof String)) { + throw new JSONException("invalid channel access token list"); + } + } + } + List tokens = AccessTokenResolver.resolve(channel); + if (tokens.isEmpty()) { + throw new JSONException("public channel has no access token"); + } + for (String token : tokens) { + if (token.length() > 1024) { + throw new JSONException("channel access token is too long"); + } + } + return tokens; + } + + private static Map parseConnections(JSONObject connectionObject, + String serviceName) + throws JSONException { + Map result = new HashMap<>(); + Iterator ids = connectionObject.keys(); + while (ids.hasNext()) { + String id = requireIdentifier(ids.next(), "connection id"); + JSONObject value = connectionObject.optJSONObject(id); + if (value == null) { + throw new JSONException("invalid connection entry"); + } + String host = requireNonBlank(value.optString("host", ""), "Mumble host"); + if (host.length() > 253 || !host.matches("[A-Za-z0-9.-]+")) { + throw new JSONException("invalid Mumble host"); + } + int port = value.optInt("port", -1); + if (port < 1 || port > 65535) { + throw new JSONException("invalid Mumble port"); + } + String password = optionalString(value, "password", "server password", 1024); + String name = value.has("name") + ? requireNonBlank(value.optString("name", ""), "connection name") + : serviceName; + Connection connection = new Connection( + id, + name, + host, + port, + normalizeMumbleUsername(value.optString("username", "")), + password, + normalizeFingerprint(value.optString("serverCertificateSha256", "")), + value.optBoolean("autoTrustServerCertificate", true)); + result.put(id, connection); + } + if (result.isEmpty()) { + throw new JSONException("no Mumble connections"); + } + return result; + } + + private static String optionalString(JSONObject object, String key, String field, + int maximumLength) throws JSONException { + if (!object.has(key)) { + return ""; + } + if (!(object.opt(key) instanceof String)) { + throw new JSONException("invalid " + field + " type"); + } + String value = object.optString(key, ""); + if (value.length() > maximumLength) { + throw new JSONException(field + " is too long"); + } + for (int index = 0; index < value.length(); index++) { + if (Character.isISOControl(value.charAt(index))) { + throw new JSONException(field + " contains a control character"); + } + } + return value; + } + + private static String requireNonBlank(String value, String field) throws JSONException { + String trimmed = value == null ? "" : value.trim(); + if (trimmed.isEmpty()) { + throw new JSONException(field + " is blank"); + } + return trimmed; + } + + private static String normalizeChannelAlias(Object rawValue, String field) + throws JSONException { + if (!(rawValue instanceof String)) { + throw new JSONException("invalid " + field + " type"); + } + String raw = (String) rawValue; + String value = raw.trim(); + if (value.isEmpty()) { + throw new JSONException(field + " is blank"); + } + if (!raw.equals(value)) { + throw new JSONException(field + " has outer whitespace"); + } + if (value.length() > MAX_CHANNEL_ALIAS_LENGTH) { + throw new JSONException(field + " is too long"); + } + for (int index = 0; index < value.length(); index++) { + if (Character.isISOControl(value.charAt(index))) { + throw new JSONException(field + " contains a control character"); + } + } + return value; + } + + private static String requireIdentifier(String value, String field) throws JSONException { + String identifier = requireNonBlank(value, field); + if (identifier.length() > 64 || !identifier.matches("[A-Za-z0-9._-]+")) { + throw new JSONException("invalid " + field); + } + return identifier; + } + + static String normalizePath(String value) throws JSONException { + String path = requireNonBlank(value, "channel path"); + if (!path.startsWith("/") || path.length() > 512 || path.contains("//")) { + throw new JSONException("invalid channel path"); + } + if (path.length() > 1 && path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return path; + } + + static String normalizeFingerprint(String value) throws JSONException { + if (value == null || value.trim().isEmpty()) { + return null; + } + String normalized = value.replace(":", "").replace(" ", "") + .toUpperCase(Locale.ROOT); + if (!normalized.matches("[0-9A-F]{64}")) { + throw new JSONException("invalid server certificate fingerprint"); + } + return normalized; + } + + static String normalizeMumbleUsername(String value) throws JSONException { + String username = requireNonBlank(value, "Mumble username"); + if (username.length() > 128) { + throw new JSONException("Mumble username is too long"); + } + for (int index = 0; index < username.length(); index++) { + if (Character.isISOControl(username.charAt(index))) { + throw new JSONException("Mumble username contains a control character"); + } + } + return username; + } + + public int getConfigVersion() { + return configVersion; + } + + public String getServiceName() { + return serviceName; + } + + public boolean isAutoConnect() { + return autoConnect; + } + + public boolean isAutoReconnect() { + return autoReconnect; + } + + public List getChannels() { + return channels; + } + + public int getDefaultChannelIndex() { + return defaultChannelIndex; + } + + public Channel getDefaultChannel() { + return channels.get(defaultChannelIndex); + } + + public int findChannelIndex(String channelId) { + if (channelId != null) { + for (int index = 0; index < channels.size(); index++) { + if (channelId.equals(channels.get(index).getId())) { + return index; + } + } + } + return -1; + } + + public static final class Connection { + private final String id; + private final String name; + private final String host; + private final int port; + private final String username; + private final String password; + private final String serverCertificateSha256; + private final boolean autoTrustServerCertificate; + + Connection(String id, String name, String host, int port, String username, + String password, String serverCertificateSha256, + boolean autoTrustServerCertificate) { + this.id = id; + this.name = name; + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.serverCertificateSha256 = serverCertificateSha256; + this.autoTrustServerCertificate = autoTrustServerCertificate; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getServerCertificateSha256() { + return serverCertificateSha256; + } + + public boolean isAutoTrustServerCertificate() { + return autoTrustServerCertificate; + } + + /** A configured pin is stricter than automatic trust and must always match when present. */ + boolean acceptsServerCertificate(String actualSha256) { + return serverCertificateSha256 == null + ? autoTrustServerCertificate + : serverCertificateSha256.equals(actualSha256); + } + + boolean matches(Server target) { + return target != null + && host.equalsIgnoreCase(target.getHost()) + && port == target.getPort() + && username.equals(target.getUsername()) + && password.equals(target.getPassword()); + } + + private boolean hasSameSessionCredentials(Connection other) { + return other != null + && id.equals(other.id) + && host.equalsIgnoreCase(other.host) + && port == other.port + && username.equals(other.username) + && password.equals(other.password) + && equalsNullable(serverCertificateSha256, other.serverCertificateSha256) + && autoTrustServerCertificate == other.autoTrustServerCertificate; + } + + private static boolean equalsNullable(String first, String second) { + return first == null ? second == null : first.equals(second); + } + } + + public static final class Channel { + private final String id; + private final String label; + private final String alias; + private final String path; + private final String presetKey; + private final Connection connection; + private final List accessTokens; + + Channel(String id, String label, String alias, String path, String presetKey, + Connection connection, List accessTokens) { + this.id = id; + this.label = label; + this.alias = alias; + this.path = path; + this.presetKey = presetKey == null ? "" : presetKey.trim(); + this.connection = connection; + this.accessTokens = Collections.unmodifiableList(new ArrayList<>(accessTokens)); + } + + public String getId() { + return id; + } + + public String getLabel() { + return label; + } + + /** Short operator-facing name shown in Minimum; falls back to the legacy label. */ + public String getAlias() { + return alias; + } + + public String getPath() { + return path; + } + + public String getPresetKey() { + return presetKey; + } + + public Connection getConnection() { + return connection; + } + + public List getAccessTokens() { + return accessTokens; + } + + public boolean requiresReconnectTo(Channel other) { + return other == null + || !connection.hasSameSessionCredentials(other.connection) + || !accessTokens.equals(other.accessTokens); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioDeviceProfile.java b/app/src/main/java/se/lublin/mumla/radio/RadioDeviceProfile.java new file mode 100644 index 00000000..4b711e98 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioDeviceProfile.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.os.Build; + +import java.util.Locale; + +/** Identifies supported radio hardware without coupling the app to one handset model. */ +public final class RadioDeviceProfile { + public static final String T99 = "t99"; + public static final String T56 = "t56"; + public static final String GENERIC = "generic-radio"; + + /** Vendor DTT_PTT from the T56 OEM keylayout is delivered to apps as keyCode 261. */ + public static final int T56_PTT_KEY_CODE = 261; + + private RadioDeviceProfile() { + } + + public static String detectCurrent() { + return detect(Build.MANUFACTURER, Build.MODEL); + } + + /** + * Uses only stable build metadata for initial profile selection. Detailed key mapping remains + * server-configurable because OEM firmware can expose the same physical button differently. + */ + public static String detect(String manufacturer, String model) { + String normalizedManufacturer = normalize(manufacturer); + String normalizedModel = normalize(model); + + if ("youdotech".equals(normalizedManufacturer) && "qm011".equals(normalizedModel)) { + return T99; + } + if ("unipro".equals(normalizedManufacturer) && "zx".equals(normalizedModel)) { + return T56; + } + return GENERIC; + } + + /** + * Returns whether this hardware profile has passed real-device location acceptance for future + * tracking. This gate is intentionally independent of remotely supplied configuration. + */ + public static boolean supportsLocationTracking(String profile) { + return T56.equals(profile); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioHardwareKeyReceiver.java b/app/src/main/java/se/lublin/mumla/radio/RadioHardwareKeyReceiver.java new file mode 100644 index 00000000..287a17fa --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioHardwareKeyReceiver.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import se.lublin.mumla.service.MumlaService; + +/** Receives the T56 firmware PTT broadcasts that remain available while keyguard owns the key. */ +public final class RadioHardwareKeyReceiver extends BroadcastReceiver { + public static final String ACTION_T56_PTT_DOWN = "unipro.hotkey.ptt.down"; + public static final String ACTION_T56_PTT_UP = "unipro.hotkey.ptt.up"; + public static final String ACTION_T56_IDENTITY_LONG = "unipro.hotkey.p2.long"; + + @Override + public void onReceive(Context context, Intent intent) { + if (context == null || intent == null + || !RadioDeviceProfile.T56.equals(RadioDeviceProfile.detectCurrent())) { + return; + } + if (ACTION_T56_IDENTITY_LONG.equals(intent.getAction())) { + try { + context.startActivity(new Intent(context, RadioShellActivity.class) + .putExtra(RadioShellActivity.EXTRA_TOGGLE_IDENTITY, true) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } catch (RuntimeException ignored) { + // The next deliberate hold can retry after a transient Activity-start failure. + } + return; + } + String serviceAction; + if (ACTION_T56_PTT_DOWN.equals(intent.getAction())) { + serviceAction = MumlaService.ACTION_RADIO_PTT_DOWN; + } else if (ACTION_T56_PTT_UP.equals(intent.getAction())) { + serviceAction = MumlaService.ACTION_RADIO_PTT_UP; + } else { + return; + } + try { + context.startService(new Intent(context, MumlaService.class).setAction(serviceAction)); + } catch (RuntimeException ignored) { + // The next hardware press can retry after a transient service-start failure. + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioKeyActionPolicy.java b/app/src/main/java/se/lublin/mumla/radio/RadioKeyActionPolicy.java new file mode 100644 index 00000000..c994cb12 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioKeyActionPolicy.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.view.KeyEvent; + +/** Timing and classification rules for deliberate radio-shell hardware actions. */ +public final class RadioKeyActionPolicy { + public static final long EXIT_HOLD_MS = 5_000L; + public static final long ROOM_CHANGE_HOLD_MS = 1_000L; + public static final long IDENTITY_HOLD_MS = 1_000L; + + private RadioKeyActionPolicy() { + } + + /** + * T99 labels do not match Android key names: MENU is DPAD_CENTER, EXIT is F2 and + * the red key reaches apps as DPAD_RIGHT even though the kernel reports KEY_BACK. + * Keep BACK as a compatibility path for firmware variants and injected diagnostics. + */ + public static boolean isProtectedExitKey(String profile, int keyCode) { + if (RadioDeviceProfile.T99.equals(profile)) { + return keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_F2 + || keyCode == KeyEvent.KEYCODE_BACK + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT; + } + // T56 exposes a dedicated BACK control; require the same deliberate recovery hold. + return RadioDeviceProfile.T56.equals(profile) && keyCode == KeyEvent.KEYCODE_BACK; + } + + public static boolean isRoomChangeKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN; + } + + public static int roomDirection(int keyCode) { + if (keyCode == KeyEvent.KEYCODE_DPAD_UP) { + return -1; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { + return 1; + } + return 0; + } + + /** Maps the deliberately-held identity control for each captured radio profile. */ + public static boolean isIdentityToggleKey(String profile, int keyCode) { + if (RadioDeviceProfile.T99.equals(profile)) { + return keyCode == KeyEvent.KEYCODE_MENU; + } + if (RadioDeviceProfile.T56.equals(profile)) { + return keyCode == KeyEvent.KEYCODE_DPAD_LEFT; + } + return false; + } + + /** Handles OEM variants while keeping T99 OK (scan 353) distinct from green (scan 139). */ + public static boolean isIdentityToggleEvent(String profile, KeyEvent event) { + if (event == null) { + return false; + } + return isIdentityToggleEvent(profile, event.getKeyCode(), event.getScanCode()); + } + + static boolean isIdentityToggleEvent(String profile, int keyCode, int scanCode) { + if (isIdentityToggleKey(profile, keyCode)) { + return true; + } + if (RadioDeviceProfile.T99.equals(profile)) { + return scanCode == 139; + } + return RadioDeviceProfile.T56.equals(profile) && scanCode == 64; + } + + public static boolean heldLongEnough(long startedAt, long endedAt, long requiredDuration) { + return startedAt >= 0L && endedAt >= startedAt + && endedAt - startedAt >= requiredDuration; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioKeyDiagnostics.java b/app/src/main/java/se/lublin/mumla/radio/RadioKeyDiagnostics.java new file mode 100644 index 00000000..c83e015b --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioKeyDiagnostics.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; +import android.view.InputDevice; +import android.view.KeyEvent; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; + +/** Stores a small app-private hardware-key trace for T99/T56 commissioning. */ +public final class RadioKeyDiagnostics { + public static final String RELATIVE_LOG_PATH = "radio-diagnostics/key-events.log"; + static final int MAX_LOG_BYTES = 32 * 1024; + private static final Object LOCK = new Object(); + + private RadioKeyDiagnostics() { + } + + public static void record(Context context, String path, KeyEvent event) { + if (context == null || event == null + || !RadioPttKeyManager.isRadioProfile(RadioDeviceProfile.detectCurrent()) + || !RadioPttKeyManager.isDiagnosticHardwareKey(event.getKeyCode())) { + return; + } + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() > 1) { + return; + } + + InputDevice device = event.getDevice(); + String deviceName = device == null ? "unknown" : sanitize(device.getName()); + String action = event.getAction() == KeyEvent.ACTION_DOWN ? "DOWN" + : event.getAction() == KeyEvent.ACTION_UP ? "UP" : "OTHER"; + String line = String.format(Locale.ROOT, + "eventTime=%d path=%s action=%s keyCode=%d keyName=%s scanCode=%d" + + " repeat=%d deviceId=%d source=0x%08X device=%s%n", + event.getEventTime(), sanitize(path), action, event.getKeyCode(), + KeyEvent.keyCodeToString(event.getKeyCode()), event.getScanCode(), + event.getRepeatCount(), event.getDeviceId(), event.getSource(), deviceName); + + synchronized (LOCK) { + File directory = new File(context.getFilesDir(), "radio-diagnostics"); + if (!directory.exists() && !directory.mkdirs()) { + return; + } + File log = new File(directory, "key-events.log"); + try { + byte[] retained = appendRecord(readTail(log), + line.getBytes(StandardCharsets.UTF_8), MAX_LOG_BYTES); + if (retained == null) { + return; + } + try (FileOutputStream stream = new FileOutputStream(log, false)) { + stream.write(retained); + } + } catch (IOException ignored) { + // Diagnostics must never interfere with PTT delivery or key release. + } + } + } + + /** + * Appends one complete record while retaining the newest complete records under the limit. + * A record that cannot fit by itself is rejected so a single event can never overflow the log. + */ + static byte[] appendRecord(byte[] existing, byte[] record, int maxBytes) { + if (record == null || record.length == 0 || maxBytes <= 0 + || record.length > maxBytes || record[record.length - 1] != '\n') { + return null; + } + byte[] current = existing == null ? new byte[0] : existing; + if (current.length > maxBytes) { + current = Arrays.copyOfRange(current, current.length - maxBytes, current.length); + } + long combinedLength = (long) current.length + record.length; + if (combinedLength > Integer.MAX_VALUE) { + return null; + } + byte[] combined = new byte[(int) combinedLength]; + System.arraycopy(current, 0, combined, 0, current.length); + System.arraycopy(record, 0, combined, current.length, record.length); + if (combined.length <= maxBytes) { + return combined; + } + return retainNewestCompleteRecords(combined, maxBytes); + } + + private static byte[] readTail(File log) throws IOException { + long length = log.length(); + if (length <= 0L) { + return new byte[0]; + } + long offset = Math.max(0L, length - MAX_LOG_BYTES); + int expected = (int) Math.min((long) MAX_LOG_BYTES, length - offset); + byte[] result = new byte[expected]; + try (FileInputStream stream = new FileInputStream(log)) { + long skipped = 0L; + while (skipped < offset) { + long count = stream.skip(offset - skipped); + if (count > 0L) { + skipped += count; + continue; + } + if (stream.read() < 0) { + return new byte[0]; + } + skipped++; + } + int position = 0; + while (position < expected) { + int count = stream.read(result, position, expected - position); + if (count < 0) { + break; + } + position += count; + } + return position == expected ? result : Arrays.copyOf(result, position); + } + } + + private static byte[] retainNewestCompleteRecords(byte[] bytes, int maxBytes) { + int lastNewline = lastIndexOfNewline(bytes, bytes.length - 1); + if (lastNewline < 0) { + return null; + } + int end = lastNewline + 1; + int start = lastIndexOfNewline(bytes, end - 2) + 1; + if (end - start > maxBytes) { + return null; + } + while (start > 0) { + int previousStart = lastIndexOfNewline(bytes, start - 2) + 1; + if (end - previousStart > maxBytes) { + break; + } + start = previousStart; + } + return Arrays.copyOfRange(bytes, start, end); + } + + private static int lastIndexOfNewline(byte[] bytes, int fromIndex) { + for (int index = Math.min(fromIndex, bytes.length - 1); index >= 0; index--) { + if (bytes[index] == '\n') { + return index; + } + } + return -1; + } + + private static String sanitize(String value) { + if (value == null) { + return "unknown"; + } + return value.replace('\n', '_').replace('\r', '_').replace('\t', '_'); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioLauncherShortcutInstaller.java b/app/src/main/java/se/lublin/mumla/radio/RadioLauncherShortcutInstaller.java new file mode 100644 index 00000000..9a1e3ce4 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioLauncherShortcutInstaller.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import se.lublin.mumla.R; +import se.lublin.mumla.app.MumlaActivity; + +/** Installs the legacy Launcher3 recovery shortcut used by API-22 radio firmware. */ +public final class RadioLauncherShortcutInstaller { + public static final String ACTION_PROVISION_SHORTCUT = + "se.lublin.mumla.action.PROVISION_LAUNCHER_SHORTCUT"; + private static final String PREF_SHORTCUT_REQUESTED = "radio_launcher_shortcut_requested"; + private static final String INSTALL_SHORTCUT_ACTION = + "com.android.launcher.action.INSTALL_SHORTCUT"; + + private RadioLauncherShortcutInstaller() { + } + + public static void ensureInstalled(Context context, SharedPreferences preferences, boolean force) { + String profile = RadioDeviceProfile.detectCurrent(); + if (!RadioDeviceProfile.T99.equals(profile) && !RadioDeviceProfile.T56.equals(profile)) { + return; + } + if (!force && preferences.getBoolean(PREF_SHORTCUT_REQUESTED, false)) { + return; + } + + Intent launchMinimum = new Intent(context, MumlaActivity.class); + launchMinimum.setAction(Intent.ACTION_MAIN); + launchMinimum.addCategory(Intent.CATEGORY_LAUNCHER); + launchMinimum.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + Intent installShortcut = new Intent(INSTALL_SHORTCUT_ACTION); + installShortcut.putExtra(Intent.EXTRA_SHORTCUT_INTENT, launchMinimum); + installShortcut.putExtra(Intent.EXTRA_SHORTCUT_NAME, context.getString(R.string.app_name)); + installShortcut.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, + Intent.ShortcutIconResource.fromContext(context, R.mipmap.ic_launcher)); + installShortcut.putExtra("duplicate", false); + context.sendBroadcast(installShortcut); + preferences.edit().putBoolean(PREF_SHORTCUT_REQUESTED, true).apply(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioNotificationPolicy.java b/app/src/main/java/se/lublin/mumla/radio/RadioNotificationPolicy.java new file mode 100644 index 00000000..34b5f0d9 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioNotificationPolicy.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +/** Notification policy that keeps managed-radio operation free of transient heads-up alerts. */ +public final class RadioNotificationPolicy { + private RadioNotificationPolicy() { + } + + public static boolean shouldShowChatNotification(boolean chatNotificationsEnabled, + boolean managedRadioDevice) { + return chatNotificationsEnabled && !managedRadioDevice; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java new file mode 100644 index 00000000..ca7b4f5c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdog.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.SystemClock; + +/** + * Maintains a process-independent recovery lease for dedicated radio profiles. + * + * The service refreshes the lease before it expires, so a healthy process never wakes the + * receiver. If the process is killed, Android retains the last alarm and the receiver can reopen + * the radio client without waiting for an OEM service-restart backoff. + */ +public final class RadioProcessWatchdog { + public static final long HEARTBEAT_INTERVAL_MS = 10_000L; + public static final long LEASE_TIMEOUT_MS = 30_000L; + static final String ACTION_LEASE_EXPIRED = + "se.lublin.mumla.action.RADIO_PROCESS_WATCHDOG"; + private static final int REQUEST_CODE = 41031; + + private RadioProcessWatchdog() { + } + + public static void arm(Context context) { + Context appContext = context.getApplicationContext(); + AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + + long triggerAt = SystemClock.elapsedRealtime() + LEASE_TIMEOUT_MS; + PendingIntent recoveryIntent = recoveryIntent(appContext); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Avoid special exact-alarm permission on modern generic devices. + alarmManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, recoveryIntent); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, recoveryIntent); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, recoveryIntent); + } else { + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, recoveryIntent); + } + } + + private static PendingIntent recoveryIntent(Context context) { + Intent intent = new Intent(context, RadioProcessWatchdogReceiver.class) + .setAction(ACTION_LEASE_EXPIRED); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getBroadcast(context, REQUEST_CODE, intent, flags); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdogReceiver.java b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdogReceiver.java new file mode 100644 index 00000000..abca30ea --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioProcessWatchdogReceiver.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** Restarts the dedicated radio UI when the service process stops refreshing its watchdog lease. */ +public final class RadioProcessWatchdogReceiver extends BroadcastReceiver { + private static final String TAG = RadioProcessWatchdogReceiver.class.getName(); + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null + || !RadioProcessWatchdog.ACTION_LEASE_EXPIRED.equals(intent.getAction()) + || !RadioPttKeyManager.isRadioProfile(RadioDeviceProfile.detectCurrent())) { + return; + } + + // Re-arm first so a blocked background launch is retried instead of silently abandoning + // recovery. A healthy service heartbeat will move this lease forward again. + RadioProcessWatchdog.arm(context); + Log.w(TAG, "Radio watchdog lease expired; launching RadioShell"); + Intent launchIntent = new Intent(context, RadioShellActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP + | Intent.FLAG_ACTIVITY_SINGLE_TOP); + try { + context.startActivity(launchIntent); + } catch (RuntimeException exception) { + // The re-armed lease will retry. Newer Android builds may require a notification-based + // foreground-service recovery path instead of a background Activity launch. + Log.w(TAG, "Could not launch RadioShell from watchdog", exception); + } + } + +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java b/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java new file mode 100644 index 00000000..7d47e510 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import androidx.preference.PreferenceManager; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import se.lublin.mumla.service.MumlaService; + +/** Narrow, shell-permission-protected ADB entry point for managed radio provisioning. */ +public final class RadioProvisionReceiver extends BroadcastReceiver { + public static final String ACTION_ASSIGN_DEVICE_PROFILE = + "se.lublin.mumla.action.PROVISION_DEVICE_PROFILE"; + public static final String ACTION_REPORT_IDENTITY = + "se.lublin.mumla.action.PROVISION_REPORT_IDENTITY"; + public static final String ACTION_INSTALL_RADIO_CONFIG = + "se.lublin.mumla.action.PROVISION_RADIO_CONFIG"; + public static final String ACTION_INSTALL_DEVICE_CONFIG_CREDENTIAL = + "se.lublin.mumla.action.PROVISION_DEVICE_CONFIG_CREDENTIAL"; + public static final String ACTION_SET_APRS_OBJECT_NAME = + "se.lublin.mumla.action.PROVISION_APRS_OBJECT_NAME"; + public static final String EXTRA_DEVICE_PROFILE = "deviceProfile"; + public static final String EXTRA_CONFIG_PATH = "configPath"; + public static final String EXTRA_DEVICE_CONFIG_CREDENTIAL = "deviceConfigCredential"; + public static final String EXTRA_DEVICE_CONFIG_CREDENTIAL_PATH = + "deviceConfigCredentialPath"; + public static final String EXTRA_APRS_OBJECT_NAME = "objectName"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + return; + } + if (RadioLauncherShortcutInstaller.ACTION_PROVISION_SHORTCUT.equals(intent.getAction())) { + RadioLauncherShortcutInstaller.ensureInstalled( + context, + PreferenceManager.getDefaultSharedPreferences(context), + true); + } else if (ACTION_ASSIGN_DEVICE_PROFILE.equals(intent.getAction())) { + String profile = intent.getStringExtra(EXTRA_DEVICE_PROFILE); + if (DeviceIdentityManager.isValidDeviceId(profile)) { + new DeviceIdentityManager(PreferenceManager.getDefaultSharedPreferences(context)) + .setDeviceIdForAdmin(profile); + } + } else if (ACTION_REPORT_IDENTITY.equals(intent.getAction())) { + String deviceId = new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId(); + setResultCode(-1); + setResultData(deviceId); + } else if (ACTION_INSTALL_RADIO_CONFIG.equals(intent.getAction())) { + String credential = intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL); + String credentialPath = intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL_PATH); + if (credential != null || credentialPath != null) { + installDeviceConfigCredential(context, credential, credentialPath); + } else { + installRadioConfig(context, intent.getStringExtra(EXTRA_CONFIG_PATH)); + } + } else if (ACTION_INSTALL_DEVICE_CONFIG_CREDENTIAL.equals(intent.getAction())) { + installDeviceConfigCredential(context, + intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL), + intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL_PATH)); + } else if (ACTION_SET_APRS_OBJECT_NAME.equals(intent.getAction())) { + updateAprsObjectName(context, intent.getStringExtra(EXTRA_APRS_OBJECT_NAME)); + } + } + + private void updateAprsObjectName(Context context, String objectName) { + setResultCode(0); + setResultData("rejected"); + try { + String deviceId = new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId(); + new RadioConfigRepository(context).updateActiveAprsObjectName(deviceId, objectName); + // The running service owns the APRS manager; ask it to re-read the active cache + // without exporting the config or credentials through the provisioning result. + MumlaService.reloadTrackingConfigIfRunning(); + setResultCode(-1); + setResultData("updated"); + } catch (IOException | RuntimeException | org.json.JSONException ignored) { + // The narrow provisioning result never exposes config content or credentials. + } + } + + private void installRadioConfig(Context context, String requestedPath) { + setResultCode(0); + setResultData("rejected"); + if (requestedPath == null) { + return; + } + try { + File configFile = new File(requestedPath).getCanonicalFile(); + File parent = configFile.getParentFile(); + if (parent == null || !"/data/local/tmp".equals(parent.getCanonicalPath()) + || !configFile.getName().startsWith("minimum-radio-config-") + || !configFile.getName().endsWith(".json")) { + return; + } + String deviceId = new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId(); + try (FileInputStream input = new FileInputStream(configFile)) { + new RadioConfigRepository(context).installProvisionedActive(input, deviceId); + } + setResultCode(-1); + setResultData("installed"); + } catch (IOException | RuntimeException | org.json.JSONException ignored) { + // Provisioning failures are returned without logging config content or credentials. + } + } + + private void installDeviceConfigCredential(Context context, String credential, + String requestedPath) { + setResultCode(0); + setResultData("rejected"); + if (credential == null && requestedPath == null) { + return; + } + try { + DeviceConfigCredentialStore store = new DeviceConfigCredentialStore(context); + if (credential != null) { + store.setCredential(credential); + } else { + File credentialFile = new File(requestedPath).getCanonicalFile(); + File parent = credentialFile.getParentFile(); + if (parent == null || !"/data/local/tmp".equals(parent.getCanonicalPath()) + || !credentialFile.getName().startsWith("minimum-device-credential-") + || !credentialFile.getName().endsWith(".txt")) { + return; + } + try (FileInputStream input = new FileInputStream(credentialFile)) { + store.setCredential(input); + } + } + RadioConfigUpdater.scheduleNow(context); + setResultCode(-1); + setResultData("credential-installed"); + } catch (IOException | RuntimeException ignored) { + // Credential failures never expose the supplied token or its contents. + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioPttKeyManager.java b/app/src/main/java/se/lublin/mumla/radio/RadioPttKeyManager.java new file mode 100644 index 00000000..cb3be689 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioPttKeyManager.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.content.SharedPreferences; +import android.view.KeyEvent; + +import se.lublin.mumla.Settings; + +/** Applies radio defaults and centralizes the multi-key PTT contract. */ +public final class RadioPttKeyManager { + private RadioPttKeyManager() { + } + + /** Makes a managed radio usable without requiring an operator to visit Mumla settings. */ + public static void applyDefaults(SharedPreferences preferences) { + String profile = RadioDeviceProfile.detectCurrent(); + if (!isRadioProfile(profile)) { + return; + } + + SharedPreferences.Editor editor = preferences.edit() + .putString(Settings.PREF_INPUT_METHOD, Settings.ARRAY_INPUT_METHOD_PTT) + .putBoolean(Settings.PREF_PTT_TOGGLE, false) + .putBoolean(Settings.PREF_AUTO_RECONNECT, true) + .putBoolean(Settings.PREF_PREPROCESSOR_ENABLED, true) + .putBoolean(Settings.PREF_HALF_DUPLEX, true) + .putBoolean(Settings.PREF_USE_TTS, true) + .putBoolean(Settings.PREF_PTT_SOUND, false); + if (RadioDeviceProfile.T99.equals(profile) + || RadioDeviceProfile.T56.equals(profile) + || !preferences.contains(Settings.PREF_PUSH_KEY)) { + editor.putInt(Settings.PREF_PUSH_KEY, RadioDeviceProfile.T56.equals(profile) + ? RadioDeviceProfile.T56_PTT_KEY_CODE : KeyEvent.KEYCODE_F1); + } + editor.apply(); + } + + /** Returns true for the primary configured key and supported radio profile defaults. */ + public static boolean isConfiguredPttKey(int keyCode, Settings settings) { + String profile = RadioDeviceProfile.detectCurrent(); + if (RadioDeviceProfile.T99.equals(profile)) { + // Physical capture proves F2 is the labelled EXIT key on T99. Never allow a stale + // preference to turn EXIT into PTT. + return isProfileDefaultPttKey(profile, keyCode); + } + if (RadioDeviceProfile.T56.equals(profile)) { + // T56 Menu is F1. Only the captured vendor DTT_PTT code may be the hardware PTT. + return isProfileDefaultPttKey(profile, keyCode); + } + if (settings != null && keyCode == settings.getPushToTalkKey()) { + return true; + } + return isProfileDefaultPttKey(profile, keyCode); + } + + public static boolean isProfileDefaultPttKey(String profile, int keyCode) { + if (RadioDeviceProfile.T99.equals(profile)) { + return keyCode == KeyEvent.KEYCODE_F1 || isMediaStyleKey(keyCode); + } + if (RadioDeviceProfile.T56.equals(profile)) { + return keyCode == RadioDeviceProfile.T56_PTT_KEY_CODE + || isMediaStyleKey(keyCode); + } + return false; + } + + public static boolean isMediaStyleKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_MEDIA_PLAY + || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + || keyCode == KeyEvent.KEYCODE_MEDIA_STOP + || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS + || keyCode == KeyEvent.KEYCODE_HEADSETHOOK; + } + + public static boolean isRadioProfile(String profile) { + return RadioDeviceProfile.T99.equals(profile) || RadioDeviceProfile.T56.equals(profile); + } + + public static boolean shouldEnablePttConfirmationSound(String profile, + boolean preferenceEnabled) { + return !isRadioProfile(profile) && preferenceEnabled; + } + + public static boolean isDiagnosticHardwareKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_POWER + || keyCode == KeyEvent.KEYCODE_F1 + || keyCode == KeyEvent.KEYCODE_F2 + || keyCode == KeyEvent.KEYCODE_VOLUME_UP + || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN + || keyCode == KeyEvent.KEYCODE_MENU + || keyCode == KeyEvent.KEYCODE_BACK + || keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_F11 + || keyCode == KeyEvent.KEYCODE_F12 + || keyCode == KeyEvent.KEYCODE_NAVIGATE_NEXT + || keyCode == KeyEvent.KEYCODE_NAVIGATE_PREVIOUS + || keyCode == KeyEvent.KEYCODE_NAVIGATE_IN + || keyCode == KeyEvent.KEYCODE_STEM_PRIMARY + || keyCode == KeyEvent.KEYCODE_STEM_1 + || keyCode == KeyEvent.KEYCODE_STEM_2 + || keyCode == KeyEvent.KEYCODE_STEM_3 + || isMediaStyleKey(keyCode); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioPttRecoveryGuard.java b/app/src/main/java/se/lublin/mumla/radio/RadioPttRecoveryGuard.java new file mode 100644 index 00000000..72baa23b --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioPttRecoveryGuard.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Process-wide latch preventing a PTT DOWN from crossing into a newly opened radio window. */ +public final class RadioPttRecoveryGuard { + private static final AtomicBoolean RELEASE_REQUIRED = new AtomicBoolean(); + + private RadioPttRecoveryGuard() { + } + + public static void requireRelease() { + RELEASE_REQUIRED.set(true); + } + + public static void noteRelease() { + RELEASE_REQUIRED.set(false); + } + + public static boolean isReleaseRequired() { + return RELEASE_REQUIRED.get(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioPttSafetyPolicy.java b/app/src/main/java/se/lublin/mumla/radio/RadioPttSafetyPolicy.java new file mode 100644 index 00000000..36f9d26b --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioPttSafetyPolicy.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +/** Pure readiness gate shared by every managed-radio PTT input path. */ +public final class RadioPttSafetyPolicy { + private RadioPttSafetyPolicy() { + } + + public static boolean canStartTransmission(boolean synchronizedSession, + boolean pttMode, + boolean managedRadio, + boolean configuredRoomReady) { + return synchronizedSession && pttMode + && (!managedRadio || configuredRoomReady); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioReceiveTracker.java b/app/src/main/java/se/lublin/mumla/radio/RadioReceiveTracker.java new file mode 100644 index 00000000..d876fe7a --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioReceiveTracker.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import se.lublin.humla.model.TalkState; + +/** Service-owned remote RX state used by config activation safety checks. */ +public final class RadioReceiveTracker { + private final Map activeSessions = new LinkedHashMap<>(); + + public synchronized void update(int session, String name, boolean self, TalkState state) { + if (self || state == null || state == TalkState.PASSIVE) { + activeSessions.remove(session); + } else { + activeSessions.put(session, name == null ? "" : name); + } + } + + public synchronized void remove(int session) { + activeSessions.remove(session); + } + + public synchronized void clear() { + activeSessions.clear(); + } + + public synchronized boolean isReceiving() { + return !activeSessions.isEmpty(); + } + + public synchronized List getActiveTalkers() { + return new ArrayList<>(activeSessions.values()); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java b/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java new file mode 100644 index 00000000..42e2e8b4 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioShellActivity.java @@ -0,0 +1,1402 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.SystemClock; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.List; + +import se.lublin.humla.HumlaService; +import se.lublin.humla.IHumlaSession; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.Server; +import se.lublin.humla.model.TalkState; +import se.lublin.humla.util.HumlaException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.app.ServerConnectTask; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.MumlaSQLiteDatabase; +import se.lublin.mumla.service.IMumlaService; +import se.lublin.mumla.service.MumlaService; +import se.lublin.mumla.util.MumlaTrustStore; + +/** Single-screen radio client driven by the Last Known Good Minimum config. */ +public final class RadioShellActivity extends AppCompatActivity { + public static final String EXTRA_CONNECT_ON_PTT = + "se.lublin.mumla.extra.CONNECT_ON_PTT"; + public static final String EXTRA_TOGGLE_IDENTITY = + "se.lublin.mumla.extra.TOGGLE_IDENTITY"; + private static final int MICROPHONE_PERMISSION_REQUEST = 73; + private static final int COLOR_READY = Color.rgb(5, 48, 38); + private static final int COLOR_RX = Color.rgb(0, 60, 68); + private static final int COLOR_BUSY = Color.rgb(82, 50, 0); + private static final int COLOR_TX = Color.rgb(92, 7, 20); + private static final int COLOR_OFFLINE = Color.rgb(25, 28, 33); + private static final int COLOR_ERROR = Color.rgb(82, 8, 18); + private static final int COLOR_CHANNEL_BADGE = Color.rgb(244, 194, 64); + private static final int COLOR_CHANNEL_TEXT = Color.rgb(25, 28, 33); + private static final String AUTOMATION_STATE_READY = "minimum-state-ready"; + private static final String PREF_SELECTED_CHANNEL_ID = "radio_selected_channel_id"; + + private IMumlaService service; + private MumlaDatabase database; + private Settings settings; + private RadioConnectionConfig config; + private int selectedRoomIndex; + private boolean bound; + private boolean destroyed; + private boolean connectRequested; + private boolean reconnectAfterDisconnect; + private boolean retryAfterConfiguredCertificateTrust; + private boolean configReceiverRegistered; + private boolean pendingConfigTrial; + private boolean pendingConfigIoInFlight; + private boolean connectionRetrySuspended; + private boolean joinedConfiguredRoom; + private int connectionAttempt; + private long txStartedElapsedRealtime; + private boolean connectOnPttRequest; + private int pendingExitKey = KeyEvent.KEYCODE_UNKNOWN; + private int pendingRoomKey = KeyEvent.KEYCODE_UNKNOWN; + private long pendingExitStartedAt = -1L; + private long pendingRoomStartedAt = -1L; + private int pendingIdentityKey = KeyEvent.KEYCODE_UNKNOWN; + private long pendingIdentityStartedAt = -1L; + private boolean identityHoldCompleted; + private boolean identityOverlayVisible; + private long lastIdentityToggleElapsedRealtime; + private int currentStatusColor = COLOR_OFFLINE; + + private final Runnable protectedExitAction = () -> { + pendingExitKey = KeyEvent.KEYCODE_UNKNOWN; + pendingExitStartedAt = -1L; + openRecoveryDashboard(); + }; + private final Runnable protectedExitPromptTick = new Runnable() { + @Override + public void run() { + if (pendingExitKey == KeyEvent.KEYCODE_UNKNOWN || destroyed) { + return; + } + setStatus(COLOR_BUSY, getString(R.string.radio_hold_to_exit)); + uiHandler.postDelayed(this, 250L); + } + }; + private final Runnable roomChangeAction = () -> { + int keyCode = pendingRoomKey; + pendingRoomKey = KeyEvent.KEYCODE_UNKNOWN; + pendingRoomStartedAt = -1L; + int direction = RadioKeyActionPolicy.roomDirection(keyCode); + if (direction != 0) { + selectRelativeRoom(direction); + } + }; + private final Runnable identityToggleAction = () -> { + if (pendingIdentityKey == KeyEvent.KEYCODE_UNKNOWN || destroyed) { + return; + } + identityHoldCompleted = true; + toggleIdentityOverlay(); + }; + + private final Handler uiHandler = new Handler(Looper.getMainLooper()); + private final Runnable radioTrafficRefresh = () -> { + if (destroyed || service == null) { + return; + } + showTrafficOrReady(); + maybeApplyPendingConfiguration(); + }; + private final Runnable txTimerTick = new Runnable() { + @Override + public void run() { + if (txStartedElapsedRealtime <= 0L || destroyed) { + return; + } + long elapsed = SystemClock.elapsedRealtime() - txStartedElapsedRealtime; + long minutes = elapsed / 60000L; + long seconds = (elapsed / 1000L) % 60L; + long tenths = (elapsed / 100L) % 10L; + txTimerView.setText(String.format(java.util.Locale.ROOT, + "%02d:%02d.%d", minutes, seconds, tenths)); + uiHandler.postDelayed(this, 100L); + } + }; + + private LinearLayout rootView; + private TextView serviceNameView; + private TextView statusView; + private TextView detailView; + private TextView txTimerView; + private TextView roomView; + private TextView identityView; + private TextView identityOverlayView; + private ProgressBar connectionProgress; + private boolean compactLayout; + + private final BroadcastReceiver configReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (RadioConfigUpdater.ACTION_CONFIG_PENDING.equals(intent.getAction())) { + maybeApplyPendingConfiguration(); + } else if (MumlaService.ACTION_RADIO_PTT_FAILURE.equals(intent.getAction())) { + stopTxTimer(); + setStatus(COLOR_ERROR, getString(R.string.radio_tx_failed)); + detailView.setText(R.string.radio_tx_failed_detail); + statusView.postDelayed(RadioShellActivity.this::updateFromService, 2500L); + } + } + }; + + private final HumlaObserver observer = new HumlaObserver() { + @Override + public void onConnecting() { + joinedConfiguredRoom = false; + connectionAttempt++; + setStatus(COLOR_BUSY, getString(R.string.radio_connecting)); + detailView.setText(getString(R.string.radio_connection_attempt, connectionAttempt)); + } + + @Override + public void onConnected() { + connectRequested = false; + connectionAttempt = 0; + connectionRetrySuspended = false; + joinedConfiguredRoom = false; + updateServiceRoomReady(false); + joinSelectedRoom(); + updateFromService(); + if (!pendingConfigTrial) { + maybeApplyPendingConfiguration(); + } + } + + @Override + public void onDisconnected(HumlaException error) { + connectRequested = false; + joinedConfiguredRoom = false; + updateServiceRoomReady(false); + stopTxTimer(); + if (retryAfterConfiguredCertificateTrust) { + retryAfterConfiguredCertificateTrust = false; + setStatus(COLOR_BUSY, getString(R.string.radio_certificate_trusted)); + statusView.postDelayed(RadioShellActivity.this::maybeConnect, 400); + return; + } + if (pendingConfigTrial && shouldRejectPendingTrial(error)) { + failPendingConfiguration(); + return; + } + if (reconnectAfterDisconnect) { + reconnectAfterDisconnect = false; + maybeConnect(); + return; + } + if (connectionRetrySuspended) { + if (service != null) { + service.cancelReconnect(); + } + setStatus(COLOR_ERROR, getString(R.string.radio_connection_blocked)); + return; + } + if (service != null && service.isReconnecting()) { + setStatus(COLOR_BUSY, getString(R.string.radio_reconnecting)); + detailView.setText(getString(R.string.radio_retry_forever)); + } else { + setStatus(COLOR_OFFLINE, getString(R.string.radio_offline)); + } + maybeApplyPendingConfiguration(); + } + + @Override + public void onTLSHandshakeFailed(X509Certificate[] chain) { + trustConfiguredCertificate(chain); + } + + @Override + public void onUserJoinedChannel(IUser user, IChannel newChannel, IChannel oldChannel) { + if (isSelf(user)) { + updateChannelAliasView(); + joinedConfiguredRoom = isSelectedRoom(newChannel); + updateServiceRoomReady(joinedConfiguredRoom); + scheduleRadioTrafficRefresh(); + if (pendingConfigTrial && isSelectedRoom(newChannel)) { + commitPendingConfiguration(); + } + } + } + + @Override + public void onUserTalkStateUpdated(IUser user) { + if (service == null || user == null) { + return; + } + if (isSelf(user)) { + if (isAudibleTalkState(user.getTalkState())) { + startTxTimer(); + } else { + stopTxTimer(); + scheduleRadioTrafficRefresh(); + } + return; + } + + scheduleRadioTrafficRefresh(); + } + + @Override + public void onUserRemoved(IUser user, String reason) { + scheduleRadioTrafficRefresh(); + } + + @Override + public void onPermissionDenied(String reason) { + setStatus(COLOR_ERROR, getString(R.string.radio_access_denied)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + } + }; + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + service = ((MumlaService.MumlaBinder) binder).getService(); + // Service-owned room readiness survives Activity stop/start (including screen-off). + // Connection and channel observers still clear it when the actual radio state changes. + service.registerObserver(observer); + updateFromService(); + maybeApplyPendingConfiguration(); + maybeConnect(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + bound = false; + stopTxTimer(); + setStatus(COLOR_OFFLINE, getString(R.string.radio_service_stopped)); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + settings = Settings.getInstance(this); + database = new MumlaSQLiteDatabase(this); + database.open(); + buildUi(); + acceptIdentityToggleIntent(getIntent()); + acceptPttRecoveryIntent(getIntent()); + loadConfiguration(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + acceptIdentityToggleIntent(intent); + acceptPttRecoveryIntent(intent); + maybeConnect(); + } + + @Override + protected void onStart() { + super.onStart(); + if (!configReceiverRegistered) { + IntentFilter appEvents = new IntentFilter(RadioConfigUpdater.ACTION_CONFIG_PENDING); + appEvents.addAction(MumlaService.ACTION_RADIO_PTT_FAILURE); + ContextCompat.registerReceiver(this, configReceiver, + appEvents, + ContextCompat.RECEIVER_NOT_EXPORTED); + configReceiverRegistered = true; + } + if (!bound) { + bound = bindService(new Intent(this, MumlaService.class), serviceConnection, + BIND_AUTO_CREATE); + } + } + + @Override + protected void onStop() { + releasePtt(); + uiHandler.removeCallbacks(radioTrafficRefresh); + cancelPendingHardwareActions(false); + if (configReceiverRegistered) { + unregisterReceiver(configReceiver); + configReceiverRegistered = false; + } + if (bound) { + if (service != null) { + service.unregisterObserver(observer); + } + unbindService(serviceConnection); + bound = false; + service = null; + } + super.onStop(); + } + + @Override + protected void onDestroy() { + destroyed = true; + stopTxTimer(); + database.close(); + super.onDestroy(); + } + + @Override + public void onBackPressed() { + openRecoveryDashboard(); + } + + private void openRecoveryDashboard() { + Intent recoveryDashboard = new Intent(this, MinimumHomeActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(recoveryDashboard); + finish(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + RadioKeyDiagnostics.record(this, "activity", event); + if (event != null && isIdentityToggleEvent(event)) { + RadioKeyDiagnostics.record(this, "identity-toggle", event); + if (event.getAction() == KeyEvent.ACTION_DOWN) { + beginIdentityToggle(event.getKeyCode(), event); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + finishIdentityToggle(event.getKeyCode(), event); + } + return true; + } + if (event != null && isProtectedExitKey(event.getKeyCode())) { + RadioKeyDiagnostics.record(this, "protected-exit", event); + if (event.getAction() == KeyEvent.ACTION_DOWN) { + beginProtectedExit(event.getKeyCode(), event); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + finishProtectedExit(event.getKeyCode(), event); + } + return true; + } + return super.dispatchKeyEvent(event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (RadioPttKeyManager.isConfiguredPttKey(keyCode, settings) + && !RadioPttKeyManager.isMediaStyleKey(keyCode)) { + if (event.getRepeatCount() == 0) { + if (RadioPttRecoveryGuard.isReleaseRequired()) { + return true; + } + boolean readyForConfiguredRoom = service != null + && service.isConnected() && joinedConfiguredRoom; + if (!readyForConfiguredRoom) { + requestConnectionFromPtt(); + } + if (service != null) { + service.onTalkKeyDown(); + } + } + return true; + } + if (RadioKeyActionPolicy.isRoomChangeKey(keyCode)) { + beginRoomChange(keyCode, event); + return true; + } + if (isIdentityToggleKey(keyCode)) { + beginIdentityToggle(keyCode, event); + return true; + } + if (isConfirmKey(keyCode)) { + if (event.getRepeatCount() == 0) { + joinSelectedRoom(); + } + return true; + } + if (keyCode == KeyEvent.KEYCODE_ENDCALL) { + if (event.getRepeatCount() == 0 && config != null) { + RadioConnectionConfig.Channel previous = getSelectedChannel(); + selectedRoomIndex = config.getDefaultChannelIndex(); + persistSelectedChannel(); + activateSelectedChannel(previous); + } + return true; + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (RadioPttKeyManager.isConfiguredPttKey(keyCode, settings) + && !RadioPttKeyManager.isMediaStyleKey(keyCode)) { + RadioPttRecoveryGuard.noteRelease(); + releasePtt(); + return true; + } + if (RadioKeyActionPolicy.isRoomChangeKey(keyCode)) { + finishRoomChange(keyCode, event); + return true; + } + if (isIdentityToggleKey(keyCode)) { + finishIdentityToggle(keyCode, event); + return true; + } + if (isNavigationKey(keyCode)) { + return true; + } + return super.onKeyUp(keyCode, event); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, + int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == MICROPHONE_PERMISSION_REQUEST) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + maybeConnect(); + } else { + setStatus(COLOR_ERROR, getString(R.string.radio_microphone_required)); + } + } + } + + @SuppressWarnings("deprecation") + private void buildUi() { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); + getWindow().setStatusBarColor(COLOR_OFFLINE); + getWindow().setNavigationBarColor(COLOR_OFFLINE); + + boolean compact = getResources().getConfiguration().screenHeightDp <= 160 + || getResources().getConfiguration().screenWidthDp <= 160; + compactLayout = compact; + rootView = new LinearLayout(this); + rootView.setOrientation(LinearLayout.VERTICAL); + rootView.setGravity(Gravity.CENTER); + rootView.setPadding(dp(5), dp(3), dp(5), dp(3)); + + serviceNameView = textView(compact ? 8 : 11, Color.LTGRAY); + serviceNameView.setGravity(Gravity.CENTER); + serviceNameView.setVisibility(View.INVISIBLE); + rootView.addView(serviceNameView, new LinearLayout.LayoutParams(-1, -2)); + + connectionProgress = new ProgressBar(this); + connectionProgress.setIndeterminate(true); + rootView.addView(connectionProgress, + new LinearLayout.LayoutParams(compact ? dp(18) : dp(28), + compact ? dp(18) : dp(28))); + + statusView = textView(compact ? 25 : 34, Color.WHITE); + statusView.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + statusView.setGravity(Gravity.CENTER); + statusView.setSingleLine(false); + statusView.setMaxLines(compact ? 2 : 4); + statusView.setEllipsize(TextUtils.TruncateAt.END); + statusView.setIncludeFontPadding(false); + rootView.addView(statusView, new LinearLayout.LayoutParams(-1, 0, 1)); + + txTimerView = textView(compact ? 21 : 30, Color.WHITE); + txTimerView.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + txTimerView.setGravity(Gravity.CENTER); + txTimerView.setVisibility(View.GONE); + rootView.addView(txTimerView, new LinearLayout.LayoutParams(-1, -2)); + + detailView = textView(compact ? 8 : 12, Color.LTGRAY); + detailView.setGravity(Gravity.CENTER); + detailView.setSingleLine(false); + detailView.setMaxLines(2); + rootView.addView(detailView, new LinearLayout.LayoutParams(-1, -2)); + + roomView = textView(compact ? 16 : 20, COLOR_CHANNEL_TEXT); + roomView.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + roomView.setGravity(Gravity.CENTER); + roomView.setSingleLine(true); + roomView.setEllipsize(TextUtils.TruncateAt.END); + roomView.setPadding(dp(compact ? 4 : 8), dp(compact ? 2 : 4), + dp(compact ? 4 : 8), dp(compact ? 2 : 4)); + GradientDrawable channelBadge = new GradientDrawable(); + channelBadge.setColor(COLOR_CHANNEL_BADGE); + channelBadge.setCornerRadius(dp(4)); + roomView.setBackground(channelBadge); + LinearLayout.LayoutParams roomParams = new LinearLayout.LayoutParams(-1, -2); + roomParams.setMargins(0, dp(1), 0, dp(1)); + rootView.addView(roomView, roomParams); + + identityView = textView(8, Color.GRAY); + identityView.setGravity(Gravity.CENTER); + String deviceId = new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(this)).getOrCreateDeviceId(); + identityView.setText(deviceId + " · " + RadioDeviceProfile.detectCurrent()); + identityView.setVisibility(View.INVISIBLE); + rootView.addView(identityView, new LinearLayout.LayoutParams(-1, -2)); + + identityOverlayView = textView(compact ? 30 : 48, Color.WHITE); + identityOverlayView.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + identityOverlayView.setGravity(Gravity.CENTER); + identityOverlayView.setText(deviceId); + identityOverlayView.setContentDescription(deviceId); + identityOverlayView.setVisibility(View.GONE); + + FrameLayout shell = new FrameLayout(this); + shell.addView(rootView, new FrameLayout.LayoutParams(-1, -1)); + shell.addView(identityOverlayView, new FrameLayout.LayoutParams(-1, -1)); + setContentView(shell); + setStatus(COLOR_OFFLINE, getString(R.string.radio_loading_config)); + } + + private TextView textView(int size, int color) { + TextView view = new TextView(this); + view.setTextSize(size); + view.setTextColor(color); + view.setSingleLine(true); + return view; + } + + private void loadConfiguration() { + new Thread(() -> { + try { + RadioConnectionConfig loaded = RadioConnectionConfig.fromJson( + new RadioConfigRepository(this).loadActiveOrDefault()); + runOnUiThread(() -> { + if (destroyed) { + return; + } + applyConfigurationToUi(loaded); + maybeApplyPendingConfiguration(); + maybeConnect(); + }); + } catch (IOException | JSONException | RuntimeException error) { + runOnUiThread(() -> setStatus(COLOR_ERROR, + getString(R.string.radio_config_invalid))); + } + }, "minimum-radio-startup").start(); + } + + private void applyConfigurationToUi(RadioConnectionConfig loaded) { + config = loaded; + connectionRetrySuspended = false; + joinedConfiguredRoom = false; + updateServiceRoomReady(false); + String savedChannelId = PreferenceManager.getDefaultSharedPreferences(this) + .getString(PREF_SELECTED_CHANNEL_ID, null); + selectedRoomIndex = loaded.findChannelIndex(savedChannelId); + if (selectedRoomIndex < 0) { + selectedRoomIndex = loaded.getDefaultChannelIndex(); + if (!pendingConfigTrial) { + persistSelectedChannel(); + } + } + serviceNameView.setText(loaded.getServiceName()); + updateChannelAliasView(); + if (!loaded.isAutoConnect()) { + setStatus(COLOR_OFFLINE, getString(R.string.radio_not_provisioned)); + } + } + + /** Staged remote config is trialled only while neither local TX nor remote RX is active. */ + private void maybeApplyPendingConfiguration() { + if (destroyed || config == null || service == null || pendingConfigTrial + || pendingConfigIoInFlight) { + return; + } + RadioConfigRepository repository = new RadioConfigRepository(this); + if (!repository.hasPending()) { + return; + } + if (!isRadioIdleForConfig()) { + setStatus(COLOR_BUSY, getString(R.string.radio_waiting_for_idle)); + return; + } + + pendingConfigIoInFlight = true; + setStatus(COLOR_BUSY, getString(R.string.radio_config_testing)); + new Thread(() -> { + try { + JSONObject pending = repository.loadPending(); + RadioConnectionConfig candidate = pending == null + ? null : RadioConnectionConfig.fromJson(pending); + runOnUiThread(() -> { + pendingConfigIoInFlight = false; + if (destroyed || candidate == null) { + return; + } + beginPendingConfigurationTrial(candidate); + }); + } catch (IOException | JSONException | RuntimeException error) { + try { + repository.discardPending(); + } catch (IOException ignored) { + // The next startup will retry or reject the same pending file safely. + } + runOnUiThread(() -> { + pendingConfigIoInFlight = false; + setStatus(COLOR_ERROR, getString(R.string.radio_config_invalid)); + }); + } + }, "minimum-radio-config-load-pending").start(); + } + + private boolean isRadioIdleForConfig() { + if (service == null) { + return false; + } + HumlaService.ConnectionState state = service.getConnectionState(); + boolean transitioning = state == HumlaService.ConnectionState.CONNECTING + || state == HumlaService.ConnectionState.CONNECTION_LOST; + boolean transmitting = false; + try { + transmitting = service.isConnected() && service.HumlaSession().isTalking(); + } catch (IllegalStateException ignored) { + return false; + } + return RadioConfigActivationPolicy.canTrial(true, transitioning, transmitting, + service.isRadioReceiving()); + } + + private static boolean isAudibleTalkState(TalkState state) { + return state == TalkState.TALKING || state == TalkState.SHOUTING + || state == TalkState.WHISPERING; + } + + private void beginPendingConfigurationTrial(RadioConnectionConfig candidate) { + pendingConfigTrial = true; + applyConfigurationToUi(candidate); + setStatus(COLOR_BUSY, getString(R.string.radio_config_testing)); + + if (!candidate.isAutoConnect()) { + commitPendingConfiguration(); + return; + } + if (service != null && service.isConnected()) { + reconnectAfterDisconnect = true; + service.disconnect(); + } else { + maybeConnect(); + } + } + + private void commitPendingConfiguration() { + if (!pendingConfigTrial || pendingConfigIoInFlight) { + return; + } + pendingConfigIoInFlight = true; + RadioConfigRepository repository = new RadioConfigRepository(this); + new Thread(() -> { + try { + repository.commitPending(); + runOnUiThread(() -> { + pendingConfigIoInFlight = false; + pendingConfigTrial = false; + if (service != null) { + service.reloadTrackingConfig(); + } + if (destroyed) { + return; + } + persistSelectedChannel(); + if (!config.isAutoConnect() && service != null && service.isConnected()) { + reconnectAfterDisconnect = false; + service.disconnect(); + setStatus(COLOR_OFFLINE, getString(R.string.radio_not_provisioned)); + } else { + showTrafficOrReady(); + } + }); + } catch (IOException | JSONException | RuntimeException error) { + runOnUiThread(() -> { + pendingConfigIoInFlight = false; + failPendingConfiguration(); + }); + } + }, "minimum-radio-config-commit").start(); + } + + private void failPendingConfiguration() { + if (!pendingConfigTrial || pendingConfigIoInFlight) { + return; + } + pendingConfigIoInFlight = true; + RadioConfigRepository repository = new RadioConfigRepository(this); + new Thread(() -> { + RadioConnectionConfig restored = null; + try { + repository.discardPending(); + restored = RadioConnectionConfig.fromJson(repository.loadActiveOrDefault()); + } catch (IOException | JSONException | RuntimeException ignored) { + // The current in-memory LKG remains safer than accepting the failed candidate. + } + RadioConnectionConfig finalRestored = restored; + runOnUiThread(() -> { + pendingConfigIoInFlight = false; + pendingConfigTrial = false; + if (destroyed) { + return; + } + if (finalRestored == null) { + setStatus(COLOR_ERROR, getString(R.string.radio_config_invalid)); + return; + } + applyConfigurationToUi(finalRestored); + setStatus(COLOR_ERROR, getString(R.string.radio_config_rolled_back)); + if (service != null) { + HumlaService.ConnectionState state = service.getConnectionState(); + if (service.isConnected() + || state == HumlaService.ConnectionState.CONNECTING) { + reconnectAfterDisconnect = finalRestored.isAutoConnect(); + service.disconnect(); + return; + } + if (service.isReconnecting() + || state == HumlaService.ConnectionState.CONNECTION_LOST) { + reconnectAfterDisconnect = false; + service.cancelReconnect(); + } + } + maybeConnect(); + }); + }, "minimum-radio-config-rollback").start(); + } + + private boolean shouldRejectPendingTrial(HumlaException error) { + if (error == null) { + return false; + } + switch (error.getReason()) { + case REJECT: + case OTHER_ERROR: + return true; + case CONNECTION_ERROR: + return RadioConfigUpdater.isNetworkConnected(this); + case USER_REMOVE: + default: + return false; + } + } + + private void maybeConnect() { + boolean explicitlyRequested = connectOnPttRequest; + if (destroyed || config == null || service == null || connectRequested + || pendingConfigIoInFlight || (!config.isAutoConnect() && !explicitlyRequested)) { + return; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, + MICROPHONE_PERMISSION_REQUEST); + return; + } + + HumlaService.ConnectionState state = service.getConnectionState(); + if (state == HumlaService.ConnectionState.CONNECTING + || (state == HumlaService.ConnectionState.CONNECTION_LOST + && service.isReconnecting())) { + connectOnPttRequest = false; + updateFromService(); + return; + } + if (service.isConnected()) { + connectOnPttRequest = false; + Server target = service.getTargetServer(); + RadioConnectionConfig.Connection connection = getSelectedChannel().getConnection(); + if (connection.matches(target)) { + joinSelectedRoom(); + updateFromService(); + return; + } + IHumlaSession session = service.HumlaSession(); + if (session.isTalking()) { + setStatus(COLOR_BUSY, getString(R.string.radio_waiting_for_idle)); + return; + } + reconnectAfterDisconnect = true; + service.disconnect(); + return; + } + + connectRequested = true; + connectOnPttRequest = false; + setStatus(COLOR_BUSY, getString(R.string.radio_preparing_identity)); + RadioCertificateManager.ensureCertificate(this, available -> { + if (!available || destroyed) { + connectRequested = false; + setStatus(COLOR_ERROR, getString(R.string.radio_certificate_failed)); + return; + } + RadioConnectionConfig.Channel channel = getSelectedChannel(); + RadioConnectionConfig.Connection connection = channel.getConnection(); + Server server = new Server(-1, connection.getName(), connection.getHost(), + connection.getPort(), connection.getUsername(), connection.getPassword()); + new ServerConnectTask(this, database, channel.getAccessTokens(), + config.isAutoReconnect()).execute(server); + }); + } + + private void updateFromService() { + if (service == null) { + return; + } + switch (service.getConnectionState()) { + case CONNECTED: + IChannel channel = getSessionChannelSafely(); + if (channel != null) { + updateChannelAliasView(); + } + showTrafficOrReady(); + break; + case CONNECTING: + setStatus(COLOR_BUSY, getString(R.string.radio_connecting)); + break; + case CONNECTION_LOST: + setStatus(service.isReconnecting() ? COLOR_BUSY : COLOR_ERROR, + getString(service.isReconnecting() + ? R.string.radio_reconnecting : R.string.radio_offline)); + break; + case DISCONNECTED: + default: + if (config == null || config.isAutoConnect()) { + setStatus(COLOR_OFFLINE, getString(R.string.radio_offline)); + } + break; + } + } + + private void trustConfiguredCertificate(X509Certificate[] chain) { + if (config == null || chain == null || chain.length == 0) { + connectionRetrySuspended = true; + setStatus(COLOR_ERROR, getString(R.string.radio_certificate_untrusted)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + return; + } + try { + RadioConnectionConfig.Connection connection = getSelectedChannel().getConnection(); + String actual = toHex(MessageDigest.getInstance("SHA-256") + .digest(chain[0].getEncoded())); + if (!connection.acceptsServerCertificate(actual)) { + if (connection.getServerCertificateSha256() == null) { + connectionRetrySuspended = true; + setStatus(COLOR_ERROR, getString(R.string.radio_certificate_untrusted)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + return; + } + connectionRetrySuspended = true; + setStatus(COLOR_ERROR, getString(R.string.radio_certificate_changed)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + return; + } + KeyStore trustStore = MumlaTrustStore.getTrustStore(this); + String alias = "minimum-" + connection.getHost() + ":" + connection.getPort(); + trustStore.setCertificateEntry(alias, chain[0]); + MumlaTrustStore.saveTrustStore(this, trustStore); + connectionRetrySuspended = false; + retryAfterConfiguredCertificateTrust = true; + setStatus(COLOR_BUSY, getString(R.string.radio_certificate_trusted)); + } catch (Exception error) { + connectionRetrySuspended = true; + setStatus(COLOR_ERROR, getString(R.string.radio_certificate_failed)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + } + } + + private static String toHex(byte[] value) { + StringBuilder result = new StringBuilder(value.length * 2); + for (byte item : value) { + result.append(String.format(java.util.Locale.ROOT, "%02X", item & 0xFF)); + } + return result.toString(); + } + + private void selectRelativeRoom(int direction) { + if (config == null || config.getChannels().isEmpty()) { + return; + } + if (service != null && service.isConnected()) { + try { + if (service.HumlaSession().isTalking()) { + setStatus(COLOR_BUSY, getString(R.string.radio_waiting_for_idle)); + return; + } + } catch (IllegalStateException ignored) { + return; + } + } + RadioConnectionConfig.Channel previous = getSelectedChannel(); + int count = config.getChannels().size(); + selectedRoomIndex = (selectedRoomIndex + direction + count) % count; + RadioConnectionConfig.Channel channel = getSelectedChannel(); + persistSelectedChannel(); + updateChannelAliasView(); + setStatus(COLOR_BUSY, getString(R.string.radio_joining_room)); + activateSelectedChannel(previous); + } + + private void activateSelectedChannel(RadioConnectionConfig.Channel previous) { + if (config == null || service == null) { + return; + } + RadioConnectionConfig.Channel selected = getSelectedChannel(); + boolean sessionCredentialsChanged = previous != null + && previous.requiresReconnectTo(selected); + HumlaService.ConnectionState state = service.getConnectionState(); + if (sessionCredentialsChanged + && state == HumlaService.ConnectionState.CONNECTING) { + reconnectAfterDisconnect = true; + service.cancelReconnect(); + service.disconnect(); + return; + } + if (sessionCredentialsChanged + && state == HumlaService.ConnectionState.CONNECTION_LOST) { + reconnectAfterDisconnect = false; + service.cancelReconnect(); + maybeConnect(); + return; + } + if (service.isConnected()) { + IHumlaSession session = service.HumlaSession(); + if (session.isTalking()) { + setStatus(COLOR_BUSY, getString(R.string.radio_waiting_for_idle)); + return; + } + if (sessionCredentialsChanged + || !selected.getConnection().matches(service.getTargetServer())) { + joinedConfiguredRoom = false; + updateServiceRoomReady(false); + reconnectAfterDisconnect = true; + service.disconnect(); + return; + } + joinSelectedRoom(); + return; + } + maybeConnect(); + } + + private void joinSelectedRoom() { + if (config == null || service == null || !service.isConnected()) { + return; + } + IHumlaSession session = service.HumlaSession(); + if (session.isTalking()) { + setStatus(COLOR_BUSY, getString(R.string.radio_waiting_for_idle)); + return; + } + RadioConnectionConfig.Channel channel = getSelectedChannel(); + IChannel target = RoomPathResolver.resolve(session.getRootChannel(), channel.getPath()); + if (target == null) { + setStatus(COLOR_ERROR, getString(R.string.radio_room_missing)); + if (pendingConfigTrial) { + failPendingConfiguration(); + } + return; + } + updateChannelAliasView(); + if (!target.equals(session.getSessionChannel())) { + joinedConfiguredRoom = false; + updateServiceRoomReady(false); + setStatus(COLOR_BUSY, getString(R.string.radio_joining_room)); + session.joinChannel(target.getId()); + } else { + joinedConfiguredRoom = true; + updateServiceRoomReady(true); + scheduleRadioTrafficRefresh(); + if (pendingConfigTrial) { + commitPendingConfiguration(); + } + } + } + + private boolean isSelectedRoom(IChannel channel) { + if (config == null || channel == null) { + return false; + } + return getSelectedChannel().getPath() + .equals(RoomPathResolver.fullPath(channel)); + } + + private RadioConnectionConfig.Channel getSelectedChannel() { + return config.getChannels().get(selectedRoomIndex); + } + + private void updateChannelAliasView() { + if (roomView == null || config == null || config.getChannels().isEmpty() + || selectedRoomIndex < 0 || selectedRoomIndex >= config.getChannels().size()) { + return; + } + String alias = getSelectedChannel().getAlias(); + roomView.setText(getString(R.string.radio_channel_alias, alias)); + roomView.setContentDescription( + getString(R.string.radio_channel_alias_accessibility, alias)); + } + + private void persistSelectedChannel() { + if (config == null || selectedRoomIndex < 0 + || selectedRoomIndex >= config.getChannels().size()) { + return; + } + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(PREF_SELECTED_CHANNEL_ID, getSelectedChannel().getId()) + .apply(); + } + + private void showTrafficOrReady() { + List activeTalkers = service == null + ? java.util.Collections.emptyList() : service.getRadioTalkers(); + RadioTrafficUiState traffic = RadioTrafficUiState.from(activeTalkers); + switch (traffic.getKind()) { + case SINGLE_TALKER: + String talker = traffic.getTalker(); + setStatus(COLOR_RX, + talker.isEmpty() ? getString(R.string.radio_receiving) : talker); + statusView.setContentDescription(talker.isEmpty() + ? getString(R.string.radio_receiving) : talker); + detailView.setText(R.string.radio_receiving); + break; + case MULTIPLE_TALKERS: + int maxLines = compactLayout ? 2 : 4; + int hiddenCount = Math.max(0, traffic.getTalkers().size() - maxLines); + RadioTalkerDisplay.Display display = RadioTalkerDisplay.format( + traffic.getTalkers(), maxLines, + getString(R.string.radio_more_talkers, hiddenCount)); + setStatus(COLOR_RX, display.getText()); + statusView.setContentDescription(display.getAccessibilityText()); + detailView.setText(R.string.radio_receiving); + break; + case READY: + default: + showReadyState(); + break; + } + } + + private void scheduleRadioTrafficRefresh() { + // Humla fans observers out through an unordered concurrent set. Deferring until the + // current fan-out completes guarantees MumlaService's tracker has applied this same event, + // regardless of whether the Activity observer happened to run first or last. + uiHandler.removeCallbacks(radioTrafficRefresh); + uiHandler.post(radioTrafficRefresh); + } + + private void showReadyState() { + if (!joinedConfiguredRoom) { + setStatus(COLOR_BUSY, getString(R.string.radio_joining_room)); + return; + } + setStatus(COLOR_READY, getString(R.string.radio_ready)); + statusView.setContentDescription(AUTOMATION_STATE_READY); + detailView.setText(R.string.radio_hardware_ptt_hint); + } + + private boolean isSelf(IUser user) { + if (service == null || user == null || !service.isConnected()) { + return false; + } + try { + return user.getSession() == service.HumlaSession().getSessionId(); + } catch (IllegalStateException ignored) { + return false; + } + } + + private IChannel getSessionChannelSafely() { + if (service == null || !service.isConnected()) { + return null; + } + try { + return service.HumlaSession().getSessionChannel(); + } catch (IllegalStateException ignored) { + return null; + } + } + + private void startTxTimer() { + if (txStartedElapsedRealtime <= 0L) { + txStartedElapsedRealtime = SystemClock.elapsedRealtime(); + txTimerView.setText("00:00.0"); + txTimerView.setVisibility(View.VISIBLE); + uiHandler.removeCallbacks(txTimerTick); + uiHandler.post(txTimerTick); + } + setStatus(COLOR_TX, getString(R.string.radio_transmitting)); + detailView.setText(R.string.radio_tx_timer_label); + } + + private void stopTxTimer() { + txStartedElapsedRealtime = 0L; + uiHandler.removeCallbacks(txTimerTick); + if (txTimerView != null) { + txTimerView.setVisibility(View.GONE); + } + } + + private void releasePtt() { + if (service != null) { + service.onTalkKeyUp(); + } + } + + private void setStatus(int color, String text) { + currentStatusColor = color; + rootView.setBackgroundColor(color); + getWindow().setStatusBarColor(color); + statusView.setBackgroundColor(Color.TRANSPARENT); + statusView.setContentDescription(null); + statusView.setText(text); + detailView.setText(""); + connectionProgress.setVisibility(color == COLOR_BUSY ? View.VISIBLE : View.GONE); + if (identityOverlayView != null) { + identityOverlayView.setBackgroundColor(color); + } + } + + private boolean isConfirmKey(int keyCode) { + if (RadioDeviceProfile.T99.equals(RadioDeviceProfile.detectCurrent())) { + return keyCode == KeyEvent.KEYCODE_MENU; + } + return keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_ENTER + || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + || keyCode == KeyEvent.KEYCODE_BUTTON_SELECT + || keyCode == KeyEvent.KEYCODE_CALL; + } + + private boolean isNavigationKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN + || keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_ENDCALL + || isProtectedExitKey(keyCode) + || isIdentityToggleKey(keyCode) + || isConfirmKey(keyCode); + } + + private boolean isIdentityToggleKey(int keyCode) { + return RadioKeyActionPolicy.isIdentityToggleKey( + RadioDeviceProfile.detectCurrent(), keyCode); + } + + private boolean isIdentityToggleEvent(KeyEvent event) { + return RadioKeyActionPolicy.isIdentityToggleEvent( + RadioDeviceProfile.detectCurrent(), event); + } + + private boolean isProtectedExitKey(int keyCode) { + return RadioKeyActionPolicy.isProtectedExitKey( + RadioDeviceProfile.detectCurrent(), keyCode); + } + + private void acceptPttRecoveryIntent(Intent intent) { + if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_ON_PTT, false)) { + connectOnPttRequest = true; + intent.removeExtra(EXTRA_CONNECT_ON_PTT); + setStatus(COLOR_BUSY, getString(R.string.radio_ptt_recovery)); + detailView.setText(R.string.radio_ptt_recovery_detail); + } + } + + private void requestConnectionFromPtt() { + connectOnPttRequest = true; + setStatus(COLOR_BUSY, getString(R.string.radio_ptt_recovery)); + detailView.setText(R.string.radio_ptt_recovery_detail); + maybeConnect(); + } + + private void beginProtectedExit(int keyCode, KeyEvent event) { + if (event.getRepeatCount() != 0 || pendingExitKey == keyCode) { + return; + } + cancelPendingHardwareActions(false); + pendingExitKey = keyCode; + pendingExitStartedAt = event.getEventTime(); + setStatus(COLOR_BUSY, getString(R.string.radio_hold_to_exit)); + uiHandler.removeCallbacks(protectedExitPromptTick); + uiHandler.postDelayed(protectedExitPromptTick, 250L); + uiHandler.postDelayed(protectedExitAction, RadioKeyActionPolicy.EXIT_HOLD_MS); + } + + private void finishProtectedExit(int keyCode, KeyEvent event) { + if (pendingExitKey != keyCode) { + return; + } + boolean completed = RadioKeyActionPolicy.heldLongEnough(pendingExitStartedAt, + event.getEventTime(), RadioKeyActionPolicy.EXIT_HOLD_MS); + pendingExitKey = KeyEvent.KEYCODE_UNKNOWN; + pendingExitStartedAt = -1L; + uiHandler.removeCallbacks(protectedExitAction); + uiHandler.removeCallbacks(protectedExitPromptTick); + if (completed) { + openRecoveryDashboard(); + } else { + updateFromService(); + } + } + + private void beginRoomChange(int keyCode, KeyEvent event) { + if (event.getRepeatCount() != 0 || pendingRoomKey == keyCode) { + return; + } + cancelPendingHardwareActions(false); + pendingRoomKey = keyCode; + pendingRoomStartedAt = event.getEventTime(); + setStatus(COLOR_BUSY, getString(R.string.radio_hold_to_change_room)); + uiHandler.postDelayed(roomChangeAction, RadioKeyActionPolicy.ROOM_CHANGE_HOLD_MS); + } + + private void finishRoomChange(int keyCode, KeyEvent event) { + if (pendingRoomKey != keyCode) { + return; + } + int direction = RadioKeyActionPolicy.roomDirection(keyCode); + boolean completed = RadioKeyActionPolicy.heldLongEnough(pendingRoomStartedAt, + event.getEventTime(), RadioKeyActionPolicy.ROOM_CHANGE_HOLD_MS); + pendingRoomKey = KeyEvent.KEYCODE_UNKNOWN; + pendingRoomStartedAt = -1L; + uiHandler.removeCallbacks(roomChangeAction); + if (completed && direction != 0) { + selectRelativeRoom(direction); + joinSelectedRoom(); + } else { + updateFromService(); + } + } + + private void beginIdentityToggle(int keyCode, KeyEvent event) { + if (event.getRepeatCount() != 0 || pendingIdentityKey == keyCode) { + return; + } + cancelPendingHardwareActions(false); + pendingIdentityKey = keyCode; + pendingIdentityStartedAt = event.getEventTime(); + identityHoldCompleted = false; + uiHandler.postDelayed(identityToggleAction, RadioKeyActionPolicy.IDENTITY_HOLD_MS); + } + + private void finishIdentityToggle(int keyCode, KeyEvent event) { + if (pendingIdentityKey != keyCode) { + return; + } + boolean completed = identityHoldCompleted; + if (!completed && RadioKeyActionPolicy.heldLongEnough(pendingIdentityStartedAt, + event.getEventTime(), RadioKeyActionPolicy.IDENTITY_HOLD_MS)) { + toggleIdentityOverlay(); + completed = true; + } + pendingIdentityKey = KeyEvent.KEYCODE_UNKNOWN; + pendingIdentityStartedAt = -1L; + identityHoldCompleted = false; + uiHandler.removeCallbacks(identityToggleAction); + if (!completed && RadioDeviceProfile.T99.equals(RadioDeviceProfile.detectCurrent())) { + joinSelectedRoom(); + } + } + + private void setIdentityOverlayVisible(boolean visible) { + identityOverlayVisible = visible; + if (identityOverlayView != null) { + identityOverlayView.setBackgroundColor(currentStatusColor); + identityOverlayView.setVisibility(visible ? View.VISIBLE : View.GONE); + } + } + + private void toggleIdentityOverlay() { + lastIdentityToggleElapsedRealtime = SystemClock.elapsedRealtime(); + setIdentityOverlayVisible(!identityOverlayVisible); + } + + private void acceptIdentityToggleIntent(Intent intent) { + if (intent == null || !intent.getBooleanExtra(EXTRA_TOGGLE_IDENTITY, false)) { + return; + } + intent.removeExtra(EXTRA_TOGGLE_IDENTITY); + long now = SystemClock.elapsedRealtime(); + if (now - lastIdentityToggleElapsedRealtime > 750L) { + toggleIdentityOverlay(); + } + } + + private void cancelPendingHardwareActions(boolean restoreStatus) { + boolean hadPendingAction = pendingExitKey != KeyEvent.KEYCODE_UNKNOWN + || pendingRoomKey != KeyEvent.KEYCODE_UNKNOWN + || pendingIdentityKey != KeyEvent.KEYCODE_UNKNOWN; + pendingExitKey = KeyEvent.KEYCODE_UNKNOWN; + pendingRoomKey = KeyEvent.KEYCODE_UNKNOWN; + pendingExitStartedAt = -1L; + pendingRoomStartedAt = -1L; + pendingIdentityKey = KeyEvent.KEYCODE_UNKNOWN; + pendingIdentityStartedAt = -1L; + identityHoldCompleted = false; + uiHandler.removeCallbacks(protectedExitAction); + uiHandler.removeCallbacks(protectedExitPromptTick); + uiHandler.removeCallbacks(roomChangeAction); + uiHandler.removeCallbacks(identityToggleAction); + if (restoreStatus && hadPendingAction) { + updateFromService(); + } + } + + private void updateServiceRoomReady(boolean ready) { + if (service != null) { + service.setRadioRoomReady(ready); + } + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioTalkerDisplay.java b/app/src/main/java/se/lublin/mumla/radio/RadioTalkerDisplay.java new file mode 100644 index 00000000..472f2b34 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioTalkerDisplay.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import java.util.ArrayList; +import java.util.List; + +/** Bounded, deterministic formatting for the small radio RX display. */ +public final class RadioTalkerDisplay { + private RadioTalkerDisplay() { + } + + public static Display format(List talkers, int maxLines, String overflowLabel) { + List names = new ArrayList<>(); + if (talkers != null) { + for (String talker : talkers) { + names.add(normalize(talker)); + } + } + int lineLimit = Math.max(1, maxLines); + int visibleCount = Math.min(names.size(), lineLimit); + int hiddenCount = names.size() - visibleCount; + List visible = new ArrayList<>(names.subList(0, visibleCount)); + if (hiddenCount > 0 && !visible.isEmpty()) { + String suffix = overflowLabel == null ? "" : overflowLabel.trim(); + if (!suffix.isEmpty()) { + int last = visible.size() - 1; + visible.set(last, visible.get(last) + " " + suffix); + } + } + String text = join(visible); + String accessibilityText = join(names); + if (hiddenCount > 0 && overflowLabel != null && !overflowLabel.trim().isEmpty()) { + accessibilityText = accessibilityText + "\n" + overflowLabel.trim(); + } + return new Display(text, accessibilityText, names.size(), visibleCount, hiddenCount); + } + + private static String normalize(String value) { + if (value == null) { + return ""; + } + return value.replace('\r', ' ').replace('\n', ' ').trim(); + } + + private static String join(List values) { + if (values.isEmpty()) { + return ""; + } + StringBuilder result = new StringBuilder(); + for (String value : values) { + if (result.length() > 0) { + result.append('\n'); + } + result.append(value); + } + return result.toString(); + } + + public static final class Display { + private final String text; + private final String accessibilityText; + private final int totalCount; + private final int visibleCount; + private final int hiddenCount; + + private Display(String text, String accessibilityText, int totalCount, + int visibleCount, int hiddenCount) { + this.text = text; + this.accessibilityText = accessibilityText; + this.totalCount = totalCount; + this.visibleCount = visibleCount; + this.hiddenCount = hiddenCount; + } + + public String getText() { + return text; + } + + public String getAccessibilityText() { + return accessibilityText; + } + + public int getTotalCount() { + return totalCount; + } + + public int getVisibleCount() { + return visibleCount; + } + + public int getHiddenCount() { + return hiddenCount; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioTrafficUiState.java b/app/src/main/java/se/lublin/mumla/radio/RadioTrafficUiState.java new file mode 100644 index 00000000..a76e8cf8 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RadioTrafficUiState.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Immutable visible RX state resolved from the service-owned talker snapshot. */ +public final class RadioTrafficUiState { + public enum Kind { + READY, + SINGLE_TALKER, + MULTIPLE_TALKERS + } + + private final Kind kind; + private final List talkers; + private final String talker; + + private RadioTrafficUiState(Kind kind, List talkers) { + this.kind = kind; + this.talkers = Collections.unmodifiableList(new ArrayList<>(talkers)); + this.talker = this.talkers.isEmpty() ? "" : this.talkers.get(0); + } + + public static RadioTrafficUiState from(List activeTalkers) { + List talkers = new ArrayList<>(); + if (activeTalkers != null) { + for (String activeTalker : activeTalkers) { + talkers.add(activeTalker == null ? "" : activeTalker); + } + } + if (talkers.isEmpty()) { + return new RadioTrafficUiState(Kind.READY, talkers); + } + return new RadioTrafficUiState(talkers.size() == 1 + ? Kind.SINGLE_TALKER : Kind.MULTIPLE_TALKERS, talkers); + } + + public Kind getKind() { + return kind; + } + + public String getTalker() { + return talker; + } + + /** Returns the complete ordered snapshot used to render simultaneous talkers. */ + public List getTalkers() { + return talkers; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/RoomPathResolver.java b/app/src/main/java/se/lublin/mumla/radio/RoomPathResolver.java new file mode 100644 index 00000000..15d274fb --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/RoomPathResolver.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import se.lublin.humla.model.IChannel; + +/** Resolves logical config room paths against a newly synchronized Mumble channel tree. */ +public final class RoomPathResolver { + private RoomPathResolver() { + } + + @Nullable + public static IChannel resolve(IChannel root, String fullPath) { + if (root == null || fullPath == null || !fullPath.startsWith("/")) { + return null; + } + if ("/".equals(fullPath)) { + return root; + } + + String[] segments = fullPath.substring(1).split("/", -1); + IChannel current = root; + for (String segment : segments) { + if (segment.isEmpty()) { + return null; + } + IChannel next = null; + for (IChannel child : safeSubchannels(current)) { + if (segment.equals(child.getName())) { + next = child; + break; + } + } + if (next == null) { + return null; + } + current = next; + } + return current; + } + + public static String fullPath(IChannel channel) { + if (channel == null) { + return "/"; + } + List segments = new ArrayList<>(); + IChannel current = channel; + while (current != null && current.getParent() != null) { + segments.add(current.getName()); + current = current.getParent(); + } + Collections.reverse(segments); + if (segments.isEmpty()) { + return "/"; + } + StringBuilder path = new StringBuilder(); + for (String segment : segments) { + path.append('/').append(segment); + } + return path.toString(); + } + + private static List safeSubchannels(IChannel channel) { + List subchannels = channel.getSubchannels(); + return subchannels == null ? Collections.emptyList() : subchannels; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsBeaconCoordinator.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsBeaconCoordinator.java new file mode 100644 index 00000000..c90a85ac --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsBeaconCoordinator.java @@ -0,0 +1,490 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import java.util.Locale; + +/** + * Thread-safe SmartBeacon and semantic duplicate-suppression pipeline. + * + * Every location/PTT/retry source enters here before transport. The coordinator retains at most + * one in-flight and one newest pending logical beacon, so reconnects cannot replay a history. + */ +public final class AprsBeaconCoordinator { + public enum MovementState { + STATIONARY, + WALKING, + VEHICLE + } + + public enum TriggerReason { + FIRST_FIX, + MOVEMENT, + TURN, + STARTED_MOVING, + STOPPED, + HEARTBEAT, + PTT + } + + public static final class Decision { + private final boolean locationAccepted; + private final boolean beaconQueued; + private final String detail; + private final MovementState movementState; + private final TriggerReason triggerReason; + + private Decision(boolean locationAccepted, boolean beaconQueued, String detail, + MovementState movementState, TriggerReason triggerReason) { + this.locationAccepted = locationAccepted; + this.beaconQueued = beaconQueued; + this.detail = detail; + this.movementState = movementState; + this.triggerReason = triggerReason; + } + + public boolean isLocationAccepted() { + return locationAccepted; + } + + public boolean isBeaconQueued() { + return beaconQueued; + } + + public String getDetail() { + return detail; + } + + public MovementState getMovementState() { + return movementState; + } + + public TriggerReason getTriggerReason() { + return triggerReason; + } + } + + public static final class Beacon { + private final long logicalId; + private final TrackingFix fix; + private final MovementState movementState; + private final TriggerReason triggerReason; + private final int attempt; + private final long nextAttemptElapsedRealtime; + + private Beacon(long logicalId, TrackingFix fix, MovementState movementState, + TriggerReason triggerReason, int attempt, + long nextAttemptElapsedRealtime) { + this.logicalId = logicalId; + this.fix = fix; + this.movementState = movementState; + this.triggerReason = triggerReason; + this.attempt = attempt; + this.nextAttemptElapsedRealtime = nextAttemptElapsedRealtime; + } + + public long getLogicalId() { + return logicalId; + } + + public TrackingFix getFix() { + return fix; + } + + public MovementState getMovementState() { + return movementState; + } + + public TriggerReason getTriggerReason() { + return triggerReason; + } + + public int getAttempt() { + return attempt; + } + + public long getNextAttemptElapsedRealtime() { + return nextAttemptElapsedRealtime; + } + + private Beacon retryAt(long when) { + return new Beacon(logicalId, fix, movementState, triggerReason, + attempt + 1, when); + } + } + + private static final long MAX_FIX_AGE_MS = 2L * 60L * 1000L; + private static final long FUTURE_FIX_TOLERANCE_MS = 30L * 1000L; + private static final float MAX_ACCURACY_METERS = 100.0f; + private static final long STOP_CONFIRMATION_MS = 2L * 60L * 1000L; + private static final long STATIONARY_HEARTBEAT_MS = 60L * 60L * 1000L; + private static final long WALKING_INTERVAL_MS = 2L * 60L * 1000L; + // APRS-IS guidance requires mobile beacon intervals of at least one minute. + private static final long VEHICLE_FAST_INTERVAL_MS = 60L * 1000L; + private static final long VEHICLE_MEDIUM_INTERVAL_MS = 90L * 1000L; + private static final long VEHICLE_SLOW_INTERVAL_MS = 120L * 1000L; + private static final long PTT_MIN_INTERVAL_MS = 2L * 60L * 1000L; + private static final long PTT_SAME_POSITION_INTERVAL_MS = 10L * 60L * 1000L; + private static final long TURN_MIN_INTERVAL_MS = 60L * 1000L; + private static final double TURN_MIN_DISTANCE_METERS = 30.0; + private static final double TURN_MIN_DEGREES = 35.0; + private static final double WALKING_SPEED_METERS_PER_SECOND = 0.7; + private static final double VEHICLE_SPEED_METERS_PER_SECOND = 6.5; + private static final double MAX_PLAUSIBLE_SPEED_METERS_PER_SECOND = 100.0; + + private TrackingFix previousFix; + private TrackingFix lastAcceptedFix; + private long lastMeaningfulMovementElapsedRealtime; + private MovementState movementState = MovementState.STATIONARY; + private Beacon lastSuccessful; + private long lastSuccessfulElapsedRealtime; + private Beacon pending; + private Beacon inFlight; + private long lastPttQueuedElapsedRealtime; + private long nextLogicalId = 1L; + + public synchronized Decision onLocation(TrackingFix fix, long nowWallTimeMillis, + long nowElapsedRealtime) { + String rejection = validateFix(fix, nowWallTimeMillis, nowElapsedRealtime); + if (rejection != null) { + return decision(false, false, rejection, null); + } + + MovementState oldState = movementState; + double distance = lastAcceptedFix == null ? 0.0 : distanceMeters(lastAcceptedFix, fix); + long deltaTime = lastAcceptedFix == null ? 0L + : Math.max(0L, fix.getElapsedRealtimeMillis() + - lastAcceptedFix.getElapsedRealtimeMillis()); + double jitterThreshold = lastAcceptedFix == null ? 0.0 + : Math.max(12.0, 0.75 * (lastAcceptedFix.getAccuracyMeters() + + fix.getAccuracyMeters())); + boolean meaningfulMovement = lastAcceptedFix != null && distance > jitterThreshold; + double speed = resolveSpeed(fix, distance, deltaTime, meaningfulMovement); + + if (meaningfulMovement) { + lastMeaningfulMovementElapsedRealtime = nowElapsedRealtime; + movementState = speed >= VEHICLE_SPEED_METERS_PER_SECOND + ? MovementState.VEHICLE : MovementState.WALKING; + } else if (oldState != MovementState.STATIONARY + && nowElapsedRealtime - lastMeaningfulMovementElapsedRealtime + >= STOP_CONFIRMATION_MS) { + movementState = MovementState.STATIONARY; + } + + TriggerReason reason = chooseReason(fix, oldState, distance, speed, + meaningfulMovement, nowElapsedRealtime); + previousFix = lastAcceptedFix; + lastAcceptedFix = fix; + if (reason == null) { + return decision(true, false, meaningfulMovement + ? "accepted-no-beacon" : "accepted-jitter-suppressed", null); + } + return queue(fix, movementState, reason, nowElapsedRealtime, true); + } + + public synchronized Decision onPtt(long nowWallTimeMillis, long nowElapsedRealtime) { + if (lastAcceptedFix == null) { + return decision(false, false, "ptt-no-location", TriggerReason.PTT); + } + String rejection = validateFix(lastAcceptedFix, nowWallTimeMillis, nowElapsedRealtime); + if (rejection != null) { + return decision(false, false, "ptt-" + rejection, TriggerReason.PTT); + } + if (nowElapsedRealtime - lastPttQueuedElapsedRealtime < PTT_MIN_INTERVAL_MS) { + return decision(true, false, "ptt-rate-limited", TriggerReason.PTT); + } + Decision decision = queue(lastAcceptedFix, movementState, TriggerReason.PTT, + nowElapsedRealtime, true); + if (decision.isBeaconQueued()) { + lastPttQueuedElapsedRealtime = nowElapsedRealtime; + } + return decision; + } + + public synchronized Beacon takeReady(long nowElapsedRealtime) { + if (inFlight != null || pending == null + || pending.getNextAttemptElapsedRealtime() > nowElapsedRealtime) { + return null; + } + inFlight = pending; + pending = null; + return inFlight; + } + + public synchronized boolean onSendSuccess(long logicalId, long nowElapsedRealtime) { + if (inFlight == null || inFlight.getLogicalId() != logicalId) { + return false; + } + lastSuccessful = inFlight; + lastSuccessfulElapsedRealtime = nowElapsedRealtime; + inFlight = null; + return true; + } + + public synchronized void onSendFailure(long logicalId, boolean uncertainDelivery, + long nowElapsedRealtime) { + if (inFlight == null || inFlight.getLogicalId() != logicalId) { + return; + } + Beacon failed = inFlight; + inFlight = null; + if (pending != null && isMeaningfullyNewer(pending, failed)) { + return; + } + long delay = retryDelayMillis(failed.getAttempt(), uncertainDelivery); + pending = failed.retryAt(nowElapsedRealtime + delay); + } + + public synchronized void onPermanentFailure(long logicalId) { + if (inFlight != null && inFlight.getLogicalId() == logicalId) { + inFlight = null; + } + if (pending != null && pending.getLogicalId() == logicalId) { + pending = null; + } + } + + public synchronized void restoreLastSuccessful(TrackingFix fix, MovementState state, + long successfulElapsedRealtime) { + if (fix == null || state == null) { + return; + } + lastSuccessful = new Beacon(0L, fix, state, TriggerReason.HEARTBEAT, + 0, successfulElapsedRealtime); + lastSuccessfulElapsedRealtime = successfulElapsedRealtime; + } + + /** Drops duplicate state when the configured APRS Object identity changes. */ + public synchronized void resetForObjectIdentity() { + lastSuccessful = null; + lastSuccessfulElapsedRealtime = 0L; + pending = null; + inFlight = null; + lastPttQueuedElapsedRealtime = 0L; + } + + public synchronized TrackingFix getLastAcceptedFix() { + return lastAcceptedFix; + } + + public synchronized MovementState getMovementState() { + return movementState; + } + + public synchronized int getQueuedBeaconCount() { + return (pending == null ? 0 : 1) + (inFlight == null ? 0 : 1); + } + + public synchronized long getNextAttemptElapsedRealtime() { + return pending == null ? -1L : pending.getNextAttemptElapsedRealtime(); + } + + private TriggerReason chooseReason(TrackingFix fix, MovementState oldState, double distance, + double speed, boolean meaningfulMovement, + long nowElapsedRealtime) { + if (lastSuccessful == null) { + return TriggerReason.FIRST_FIX; + } + if (oldState == MovementState.STATIONARY && movementState != MovementState.STATIONARY) { + return TriggerReason.STARTED_MOVING; + } + if (oldState != MovementState.STATIONARY && movementState == MovementState.STATIONARY) { + return TriggerReason.STOPPED; + } + + long sinceSuccessful = nowElapsedRealtime - lastSuccessfulElapsedRealtime; + double sinceSuccessfulDistance = distanceMeters(lastSuccessful.getFix(), fix); + if (movementState != MovementState.STATIONARY && meaningfulMovement + && sinceSuccessful >= TURN_MIN_INTERVAL_MS + && sinceSuccessfulDistance >= TURN_MIN_DISTANCE_METERS + && isSignificantTurn(fix)) { + return TriggerReason.TURN; + } + + long interval = beaconIntervalMillis(movementState, speed); + if (sinceSuccessful >= interval) { + return movementState == MovementState.STATIONARY + ? TriggerReason.HEARTBEAT : TriggerReason.MOVEMENT; + } + double distanceThreshold = movementState == MovementState.WALKING + ? 120.0 + : movementState == MovementState.VEHICLE + ? Math.max(120.0, speed * (interval / 1000.0) * 0.7) + : Double.POSITIVE_INFINITY; + if (meaningfulMovement && sinceSuccessfulDistance >= distanceThreshold) { + return TriggerReason.MOVEMENT; + } + return null; + } + + private boolean isSignificantTurn(TrackingFix fix) { + if (previousFix == null || lastAcceptedFix == null) { + return false; + } + double previousCourse = bearingDegrees(previousFix, lastAcceptedFix); + double currentCourse = fix.hasBearing() + ? fix.getBearingDegrees() : bearingDegrees(lastAcceptedFix, fix); + double difference = Math.abs(previousCourse - currentCourse) % 360.0; + difference = Math.min(difference, 360.0 - difference); + return difference >= TURN_MIN_DEGREES; + } + + private Decision queue(TrackingFix fix, MovementState state, TriggerReason reason, + long nowElapsedRealtime, boolean locationAccepted) { + Beacon candidate = new Beacon(nextLogicalId, fix, state, reason, 0, + nowElapsedRealtime); + if (isDuplicateOf(candidate, inFlight, nowElapsedRealtime) + || isDuplicateOf(candidate, pending, nowElapsedRealtime) + || isDuplicateOfLastSuccess(candidate, nowElapsedRealtime)) { + return decision(locationAccepted, false, + "duplicate-" + reason.name().toLowerCase(Locale.ROOT), reason); + } + nextLogicalId++; + if (pending == null || isMeaningfullyNewer(candidate, pending) + || isTransition(reason)) { + pending = candidate; + return decision(locationAccepted, true, + "queued-" + reason.name().toLowerCase(Locale.ROOT), reason); + } + return decision(locationAccepted, false, "older-than-pending", reason); + } + + private boolean isDuplicateOfLastSuccess(Beacon candidate, long nowElapsedRealtime) { + if (lastSuccessful == null || isTransition(candidate.getTriggerReason()) + || candidate.getTriggerReason() == TriggerReason.HEARTBEAT) { + return false; + } + if (!isNear(candidate.getFix(), lastSuccessful.getFix()) + || candidate.getMovementState() != lastSuccessful.getMovementState()) { + return false; + } + long elapsed = nowElapsedRealtime - lastSuccessfulElapsedRealtime; + if (candidate.getTriggerReason() == TriggerReason.PTT) { + return elapsed < PTT_SAME_POSITION_INTERVAL_MS; + } + return true; + } + + private static boolean isDuplicateOf(Beacon candidate, Beacon existing, + long nowElapsedRealtime) { + if (existing == null || isTransition(candidate.getTriggerReason())) { + return false; + } + return candidate.getMovementState() == existing.getMovementState() + && isNear(candidate.getFix(), existing.getFix()); + } + + private static boolean isMeaningfullyNewer(Beacon candidate, Beacon existing) { + return candidate.getFix().getElapsedRealtimeMillis() + > existing.getFix().getElapsedRealtimeMillis() + && (!isNear(candidate.getFix(), existing.getFix()) + || candidate.getMovementState() != existing.getMovementState() + || isTransition(candidate.getTriggerReason())); + } + + private static boolean isTransition(TriggerReason reason) { + return reason == TriggerReason.STARTED_MOVING || reason == TriggerReason.STOPPED; + } + + private Decision decision(boolean locationAccepted, boolean queued, String detail, + TriggerReason reason) { + return new Decision(locationAccepted, queued, detail, movementState, reason); + } + + private static String validateFix(TrackingFix fix, long nowWallTimeMillis, + long nowElapsedRealtime) { + if (fix == null || !fix.hasValidCoordinates()) { + return "invalid-location"; + } + if (!Float.isFinite(fix.getAccuracyMeters()) || fix.getAccuracyMeters() <= 0.0f + || fix.getAccuracyMeters() > MAX_ACCURACY_METERS) { + return "poor-accuracy"; + } + long elapsedAge = fix.getElapsedRealtimeMillis() > 0L + ? nowElapsedRealtime - fix.getElapsedRealtimeMillis() + : nowWallTimeMillis - fix.getWallTimeMillis(); + if (elapsedAge > MAX_FIX_AGE_MS) { + return "stale-location"; + } + if (elapsedAge < -FUTURE_FIX_TOLERANCE_MS) { + return "future-location"; + } + return null; + } + + private static double resolveSpeed(TrackingFix fix, double distance, long deltaTime, + boolean meaningfulMovement) { + if (!meaningfulMovement) { + return 0.0; + } + if (fix.hasSpeed() && fix.getSpeedMetersPerSecond() + <= MAX_PLAUSIBLE_SPEED_METERS_PER_SECOND) { + return fix.getSpeedMetersPerSecond(); + } + if (deltaTime < 1000L) { + return 0.0; + } + return Math.min(MAX_PLAUSIBLE_SPEED_METERS_PER_SECOND, + distance / (deltaTime / 1000.0)); + } + + private static long beaconIntervalMillis(MovementState state, double speed) { + if (state == MovementState.STATIONARY) { + return STATIONARY_HEARTBEAT_MS; + } + if (state == MovementState.WALKING) { + return WALKING_INTERVAL_MS; + } + if (speed >= 27.0) { + return VEHICLE_FAST_INTERVAL_MS; + } + if (speed >= 13.0) { + return VEHICLE_MEDIUM_INTERVAL_MS; + } + return VEHICLE_SLOW_INTERVAL_MS; + } + + private static long retryDelayMillis(int attempt, boolean uncertainDelivery) { + if (uncertainDelivery) { + return Math.min(60L * 60L * 1000L, + 15L * 60L * 1000L * (1L << Math.min(2, attempt))); + } + long[] delays = {60L * 1000L, 5L * 60L * 1000L, + 15L * 60L * 1000L, 60L * 60L * 1000L}; + return delays[Math.min(attempt, delays.length - 1)]; + } + + private static boolean isNear(TrackingFix first, TrackingFix second) { + double threshold = Math.max(15.0, + Math.min(75.0, Math.max(first.getAccuracyMeters(), second.getAccuracyMeters()))); + return distanceMeters(first, second) <= threshold; + } + + static double distanceMeters(TrackingFix first, TrackingFix second) { + double latitude1 = Math.toRadians(first.getLatitude()); + double latitude2 = Math.toRadians(second.getLatitude()); + double latitudeDelta = latitude2 - latitude1; + double longitudeDelta = Math.toRadians(second.getLongitude() - first.getLongitude()); + double a = Math.sin(latitudeDelta / 2.0) * Math.sin(latitudeDelta / 2.0) + + Math.cos(latitude1) * Math.cos(latitude2) + * Math.sin(longitudeDelta / 2.0) * Math.sin(longitudeDelta / 2.0); + return 6371000.0 * 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a)); + } + + private static double bearingDegrees(TrackingFix first, TrackingFix second) { + double latitude1 = Math.toRadians(first.getLatitude()); + double latitude2 = Math.toRadians(second.getLatitude()); + double longitudeDelta = Math.toRadians(second.getLongitude() - first.getLongitude()); + double y = Math.sin(longitudeDelta) * Math.cos(latitude2); + double x = Math.cos(latitude1) * Math.sin(latitude2) + - Math.sin(latitude1) * Math.cos(latitude2) * Math.cos(longitudeDelta); + return (Math.toDegrees(Math.atan2(y, x)) + 360.0) % 360.0; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsHealthSnapshot.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsHealthSnapshot.java new file mode 100644 index 00000000..0193c0bd --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsHealthSnapshot.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.BatteryManager; +import android.os.StatFs; +import android.telephony.TelephonyManager; + +import java.util.Locale; + +/** A redacted, compact health snapshot suitable for an APRS comment. */ +public final class AprsHealthSnapshot { + public static final int UNKNOWN = Integer.MIN_VALUE; + + private final int batteryPercent; + private final boolean charging; + private final int batteryTemperatureC10; + private final int wifiRssiDbm; + private final String mobileType; + private final int mobileRssiDbm; + private final long freeStorageMb; + + AprsHealthSnapshot(int batteryPercent, boolean charging, int batteryTemperatureC10, + int wifiRssiDbm, String mobileType, int mobileRssiDbm, + long freeStorageMb) { + this.batteryPercent = batteryPercent; + this.charging = charging; + this.batteryTemperatureC10 = batteryTemperatureC10; + this.wifiRssiDbm = wifiRssiDbm; + this.mobileType = mobileType == null ? "" : mobileType; + this.mobileRssiDbm = mobileRssiDbm; + this.freeStorageMb = freeStorageMb; + } + + public static AprsHealthSnapshot capture(Context context, int mobileRssiDbm) { + if (context == null) { + return new AprsHealthSnapshot(UNKNOWN, false, UNKNOWN, UNKNOWN, "", + mobileRssiDbm, UNKNOWN); + } + Intent battery = context.registerReceiver(null, + new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + int level = battery == null ? UNKNOWN : battery.getIntExtra(BatteryManager.EXTRA_LEVEL, + UNKNOWN); + int scale = battery == null ? UNKNOWN : battery.getIntExtra(BatteryManager.EXTRA_SCALE, + UNKNOWN); + int percent = level >= 0 && scale > 0 ? Math.min(100, Math.max(0, + Math.round(level * 100.0f / scale))) : UNKNOWN; + int status = battery == null ? BatteryManager.BATTERY_STATUS_UNKNOWN + : battery.getIntExtra(BatteryManager.EXTRA_STATUS, + BatteryManager.BATTERY_STATUS_UNKNOWN); + boolean charging = status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + int temperature = battery == null ? UNKNOWN + : battery.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, UNKNOWN); + + int wifiRssi = UNKNOWN; + try { + WifiManager wifi = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + WifiInfo info = wifi == null ? null : wifi.getConnectionInfo(); + if (info != null && info.getNetworkId() >= 0) { + wifiRssi = info.getRssi(); + } + } catch (RuntimeException ignored) { + // Wi-Fi may be unavailable or permission-restricted on an OEM build. + } + + String mobileType = ""; + try { + TelephonyManager telephony = (TelephonyManager) context.getSystemService( + Context.TELEPHONY_SERVICE); + if (telephony != null) { + mobileType = networkTypeCode(telephony.getNetworkType()); + } + } catch (SecurityException ignored) { + // Keep the rest of the health snapshot when phone state is unavailable. + } + + long freeStorageMb = UNKNOWN; + try { + StatFs stats = new StatFs(context.getFilesDir().getAbsolutePath()); + freeStorageMb = (stats.getAvailableBlocksLong() * stats.getBlockSizeLong()) + / (1024L * 1024L); + } catch (RuntimeException ignored) { + // Storage statistics are optional health data. + } + return new AprsHealthSnapshot(percent, charging, temperature, wifiRssi, + mobileType, mobileRssiDbm, freeStorageMb); + } + + static String networkTypeCode(int type) { + switch (type) { + case TelephonyManager.NETWORK_TYPE_LTE: + return "4G"; + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_1xRTT: + return "2G"; + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_IDEN: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + return "3G"; + default: + return type == TelephonyManager.NETWORK_TYPE_UNKNOWN ? "" : "M"; + } + } + + /** Formats health as a compact ASCII comment; no identifiers or coordinates are included. */ + public String toAprsComment(AprsBeaconCoordinator.MovementState state) { + return toAprsComment(state, TrackingFix.UNKNOWN); + } + + /** Adds the accuracy of the fix used by the position report when it is available. */ + public String toAprsComment(AprsBeaconCoordinator.MovementState state, + float accuracyMeters) { + String stateCode = state == null ? "ST" : state == AprsBeaconCoordinator.MovementState.VEHICLE + ? "VE" : state == AprsBeaconCoordinator.MovementState.WALKING ? "WA" : "ST"; + StringBuilder result = new StringBuilder("T56 ").append(stateCode); + if (Float.isFinite(accuracyMeters) && accuracyMeters > 0.0f) { + result.append(" A").append(Math.round(accuracyMeters)); + } + if (batteryPercent != UNKNOWN) { + result.append(" B").append(batteryPercent).append('%'); + if (charging) { + result.append('+'); + } + } + if (batteryTemperatureC10 != UNKNOWN) { + result.append(" T").append(String.format(Locale.ROOT, "%.1f", + batteryTemperatureC10 / 10.0f)); + } + if (wifiRssiDbm != UNKNOWN) { + result.append(" W").append(wifiRssiDbm); + } + if (!mobileType.isEmpty()) { + result.append(' ').append(mobileType); + if (mobileRssiDbm != UNKNOWN) { + result.append(mobileRssiDbm); + } else { + result.append("NA"); + } + } else { + result.append(" MNA"); + } + if (freeStorageMb != UNKNOWN) { + result.append(" S").append(Math.max(0L, freeStorageMb / 1024L)).append('G'); + } + return result.toString(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsHttpTransport.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsHttpTransport.java new file mode 100644 index 00000000..aa9a6ea2 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsHttpTransport.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import android.util.Base64; + +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.HttpURLConnection; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +import javax.net.ssl.HttpsURLConnection; + +import se.lublin.mumla.R; + +/** One-shot authenticated APRS-IS HTTPS send-only transport. */ +public final class AprsIsHttpTransport implements AprsTransport { + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 10_000; + private final SSLSocketFactory sslSocketFactory; + + public AprsIsHttpTransport() { + this(null); + } + + AprsIsHttpTransport(android.content.Context context) { + sslSocketFactory = context == null ? null : createPinnedSocketFactory(context); + } + + @Override + public SendResult send(AprsTrackingConfig config, String packet) { + if (config == null || !config.isAprsEnabled()) { + return SendResult.retryable("tracking-not-configured"); + } + if (!isValidPacketLine(packet)) { + return SendResult.permanent("aprs-invalid-packet"); + } + HttpsURLConnection connection = null; + boolean bodyWriteStarted = false; + try { + connection = (HttpsURLConnection) new URL("https", config.getHost(), + config.getPort(), "/").openConnection(); + byte[] body = packet.getBytes(StandardCharsets.US_ASCII); + byte[] credential = (config.getSourceCallsign() + ":" + config.getPasscode()) + .getBytes(StandardCharsets.US_ASCII); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + if (sslSocketFactory != null) { + connection.setSSLSocketFactory(sslSocketFactory); + } + connection.setFixedLengthStreamingMode(body.length); + connection.setRequestProperty("Accept", "text/plain"); + connection.setRequestProperty("Content-Type", "application/octet-stream"); + connection.setRequestProperty("Authorization", "Basic " + + Base64.encodeToString(credential, Base64.NO_WRAP)); + try (OutputStream output = connection.getOutputStream()) { + // Set this immediately before the first body byte so connect failures remain retryable. + bodyWriteStarted = true; + output.write(body); + output.flush(); + } + int statusCode = connection.getResponseCode(); + return classifyResponse(statusCode, connection.getHeaderField("X-Packetsrcvd")); + } catch (IOException exception) { + android.util.Log.w("MinimumAprs", "APRS HTTPS transport failed before=" + + bodyWriteStarted + " type=" + exception.getClass().getSimpleName() + + " cause=" + (exception.getCause() == null + ? "none" : exception.getCause().getClass().getSimpleName()) + + " message=" + String.valueOf(exception.getMessage())); + return bodyWriteStarted + ? SendResult.uncertain("aprs-is-https-after-write") + : SendResult.retryable("aprs-is-https-before-write"); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + static SendResult classifyResponse(int statusCode, String packetsReceived) { + if (statusCode == HttpURLConnection.HTTP_NO_CONTENT) { + try { + if (Integer.parseInt(packetsReceived) > 0) { + return SendResult.success(); + } + } catch (NumberFormatException ignored) { + // A 204 without the documented count is not sufficient confirmation. + } + return SendResult.uncertain("aprs-is-https-missing-receipt"); + } + if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED + || statusCode == HttpURLConnection.HTTP_FORBIDDEN + || statusCode == HttpURLConnection.HTTP_BAD_REQUEST + || statusCode == HttpURLConnection.HTTP_LENGTH_REQUIRED + || statusCode == 413 + || statusCode == HttpURLConnection.HTTP_UNSUPPORTED_TYPE + || statusCode == 417) { + return SendResult.permanent("aprs-is-https-rejected-" + statusCode); + } + return SendResult.retryable("aprs-is-https-status-" + statusCode); + } + + static boolean isValidPacketLine(String packet) { + return AprsIsTcpTransport.isValidPacketLine(packet); + } + + private static SSLSocketFactory createPinnedSocketFactory(android.content.Context context) { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Certificate root; + try (InputStream input = context.getResources().openRawResource(R.raw.isrg_root_x1)) { + root = certificateFactory.generateCertificate(input); + } + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry("isrg-root-x1", root); + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(new KeyManager[0], trustManagerFactory.getTrustManagers(), + new SecureRandom()); + return new Tls12SocketFactory(sslContext.getSocketFactory()); + } catch (Exception exception) { + android.util.Log.w("MinimumAprs", "APRS CA setup failed: " + + exception.getClass().getSimpleName()); + return null; + } + } + + /** Enables TLS 1.2 on API-22 sockets while preserving normal hostname verification. */ + private static final class Tls12SocketFactory extends SSLSocketFactory { + private final SSLSocketFactory delegate; + + Tls12SocketFactory(SSLSocketFactory delegate) { + this.delegate = delegate; + } + + @Override public String[] getDefaultCipherSuites() { return delegate.getDefaultCipherSuites(); } + @Override public String[] getSupportedCipherSuites() { return delegate.getSupportedCipherSuites(); } + + @Override public Socket createSocket(Socket socket, String host, int port, boolean autoClose) + throws IOException { + return enable(delegate.createSocket(socket, host, port, autoClose)); + } + + @Override public Socket createSocket(String host, int port) throws IOException { + return enable(delegate.createSocket(host, port)); + } + + @Override public Socket createSocket(String host, int port, InetAddress localHost, + int localPort) throws IOException { + return enable(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + return enable(delegate.createSocket(host, port)); + } + + @Override public Socket createSocket(InetAddress address, int port, + InetAddress localAddress, int localPort) + throws IOException { + return enable(delegate.createSocket(address, port, localAddress, localPort)); + } + + private static Socket enable(Socket socket) { + if (socket instanceof SSLSocket) { + SSLSocket sslSocket = (SSLSocket) socket; + String[] supported = sslSocket.getSupportedProtocols(); + for (String protocol : supported) { + if ("TLSv1.2".equals(protocol)) { + sslSocket.setEnabledProtocols(new String[] {"TLSv1.2"}); + break; + } + } + } + return socket; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsTcpTransport.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsTcpTransport.java new file mode 100644 index 00000000..c4e1c111 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsIsTcpTransport.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +/** Short-lived APRS-IS TCP submitter; it never holds a tracking connection open. */ +public final class AprsIsTcpTransport implements AprsTransport { + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 10_000; + private static final int MAX_GREETING_LINES = 32; + + @Override + public SendResult send(AprsTrackingConfig config, String packet) { + if (config == null || !config.isAprsEnabled() || packet == null || packet.isEmpty()) { + return SendResult.retryable("tracking-not-configured"); + } + if (!isValidPacketLine(packet)) { + return SendResult.permanent("aprs-invalid-packet"); + } + boolean packetWriteStarted = false; + try (Socket socket = new Socket()) { + socket.setTcpNoDelay(true); + socket.connect(new InetSocketAddress(config.getHost(), config.getPort()), + CONNECT_TIMEOUT_MS); + socket.setSoTimeout(READ_TIMEOUT_MS); + BufferedReader reader = new BufferedReader(new InputStreamReader( + socket.getInputStream(), StandardCharsets.US_ASCII)); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + socket.getOutputStream(), StandardCharsets.US_ASCII)); + + String greeting = reader.readLine(); + if (greeting == null || !greeting.startsWith("#")) { + return SendResult.retryable("aprs-is-greeting-invalid"); + } + + writer.write("user "); + writer.write(config.getSourceCallsign()); + writer.write(" pass "); + writer.write(config.getPasscode()); + writer.write(" vers Minimum 1.0\r\n"); + writer.flush(); + LoginResponse loginResponse = readLoginResponse(reader, config.getSourceCallsign()); + if (loginResponse == LoginResponse.REJECTED) { + return SendResult.permanent("aprs-is-login-rejected"); + } + if (loginResponse != LoginResponse.VERIFIED) { + return SendResult.retryable("aprs-is-login-response-invalid"); + } + + packetWriteStarted = true; + writer.write(packet); + writer.write("\r\n"); + writer.flush(); + return SendResult.success(); + } catch (java.net.SocketTimeoutException timeout) { + return packetWriteStarted + ? SendResult.uncertain("aprs-is-timeout-after-write") + : SendResult.retryable("aprs-is-timeout-before-write"); + } catch (IOException exception) { + return packetWriteStarted + ? SendResult.uncertain("aprs-is-io-after-write") + : SendResult.retryable("aprs-is-io-before-write"); + } + } + + static boolean isValidPacketLine(String packet) { + if (packet == null || packet.isEmpty() || packet.length() > 510 + || packet.indexOf('\r') >= 0 || packet.indexOf('\n') >= 0) { + return false; + } + byte[] ascii = packet.getBytes(StandardCharsets.US_ASCII); + return ascii.length <= 510 && packet.equals(new String(ascii, StandardCharsets.US_ASCII)); + } + + enum LoginResponse { + VERIFIED, + REJECTED, + INVALID + } + + static LoginResponse parseLoginResponse(String line, String expectedCallsign) { + if (line == null || expectedCallsign == null || !line.startsWith("# logresp ")) { + return LoginResponse.INVALID; + } + String[] fields = line.substring("# logresp ".length()).trim().split("\\s+", 4); + if (fields.length >= 2) { + String status = fields[1].replace(",", ""); + if ("unverified".equalsIgnoreCase(status) || "bad".equalsIgnoreCase(status)) { + return LoginResponse.REJECTED; + } + } + if (fields.length < 2 || !expectedCallsign.equalsIgnoreCase(fields[0])) { + return LoginResponse.INVALID; + } + return fields.length >= 4 && "verified,".equalsIgnoreCase(fields[1]) + && "server".equalsIgnoreCase(fields[2]) && !fields[3].isEmpty() + ? LoginResponse.VERIFIED : LoginResponse.INVALID; + } + + private static LoginResponse readLoginResponse(BufferedReader reader, String expectedCallsign) + throws IOException { + for (int lineCount = 0; lineCount < MAX_GREETING_LINES; lineCount++) { + String line = reader.readLine(); + if (line == null) { + return LoginResponse.INVALID; + } + if (line.startsWith("# logresp ")) { + return parseLoginResponse(line, expectedCallsign); + } + } + return LoginResponse.INVALID; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsObjectName.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsObjectName.java new file mode 100644 index 00000000..78388ade --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsObjectName.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +/** Maps a public Minimum Device ID or configured label to APRS' fixed nine-character Object name. */ +public final class AprsObjectName { + public static final int APRS_OBJECT_NAME_LENGTH = 9; + private static final String OBJECT_PREFIX = "VR-"; + private static final char[] BASE36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + private AprsObjectName() { + } + + public static String fromDeviceId(String deviceId) { + String normalized = deviceId == null ? "" : deviceId.trim().toUpperCase(Locale.ROOT); + if (normalized.matches("[A-Z0-9]{1,6}")) { + return pad(OBJECT_PREFIX + normalized); + } + + // Defensive fallback for a future identity format: retain a recognizable prefix and add + // a stable four-character digest. Current six-character Device IDs take the direct path. + String readable = normalized.replaceAll("[^A-Z0-9]", ""); + if (readable.isEmpty()) { + readable = "MIN"; + } + readable = readable.substring(0, Math.min(3, readable.length())); + return pad(OBJECT_PREFIX + readable + digestBase36(normalized).substring(0, 3)); + } + + /** + * Normalizes an optional configured APRS Object label. APRS Object names are nine bytes on + * the wire, so shorter labels are right-padded with spaces after validation. + */ + public static String fromConfiguredName(String configuredName) { + return pad(normalizeConfiguredName(configuredName)); + } + + /** Returns the validated unpadded label suitable for storing in JSON configuration. */ + public static String normalizeConfiguredName(String configuredName) { + if (configuredName == null) { + throw new IllegalArgumentException("APRS Object name is missing"); + } + String normalized = configuredName.toUpperCase(Locale.ROOT); + if (!normalized.matches("[A-Z0-9](?:[A-Z0-9 _-]{0,7}[A-Z0-9_-])?")) { + throw new IllegalArgumentException("invalid APRS Object name"); + } + return normalized; + } + + private static String pad(String value) { + StringBuilder result = new StringBuilder(value.substring(0, + Math.min(APRS_OBJECT_NAME_LENGTH, value.length()))); + while (result.length() < APRS_OBJECT_NAME_LENGTH) { + result.append(' '); + } + return result.toString(); + } + + private static String digestBase36(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + long number = ((long) (digest[0] & 0xff) << 24) + | ((long) (digest[1] & 0xff) << 16) + | ((long) (digest[2] & 0xff) << 8) + | (digest[3] & 0xffL); + char[] result = new char[4]; + for (int index = result.length - 1; index >= 0; index--) { + result[index] = BASE36[(int) (number % BASE36.length)]; + number /= BASE36.length; + } + return new String(result); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsPacketEncoder.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsPacketEncoder.java new file mode 100644 index 00000000..91bd8450 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsPacketEncoder.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** Encodes conventional uncompressed APRS position and Object reports for APRS-IS. */ +public final class AprsPacketEncoder { + private static final double METERS_PER_SECOND_TO_KNOTS = 1.9438444924406; + + private AprsPacketEncoder() { + } + + /** Encodes a timestamped position report for a configured APRS source. */ + public static String encodePosition(String sourceCallsign, TrackingFix fix, + char symbolTable, char symbolCode, String comment) { + String source = normalizeCallsign(sourceCallsign); + validatePosition(fix, symbolTable, symbolCode); + + StringBuilder packet = new StringBuilder(80); + packet.append(source).append(">APRS,TCPIP*:@") + .append(formatTimestamp(fix.getWallTimeMillis())) + .append(formatLatitude(fix.getLatitude())) + .append(symbolTable) + .append(formatLongitude(fix.getLongitude())) + .append(symbolCode); + appendMovementAndComment(packet, fix, comment); + return packet.toString(); + } + + public static String encodeObject(String sourceCallsign, String objectName, + TrackingFix fix, char symbolTable, char symbolCode, + String comment) { + String source = normalizeCallsign(sourceCallsign); + if (objectName == null || objectName.length() != AprsObjectName.APRS_OBJECT_NAME_LENGTH + || objectName.indexOf(';') >= 0 || objectName.indexOf('\r') >= 0 + || objectName.indexOf('\n') >= 0) { + throw new IllegalArgumentException("APRS Object name must be exactly nine characters"); + } + validatePosition(fix, symbolTable, symbolCode); + + StringBuilder packet = new StringBuilder(96); + packet.append(source).append(">APRS,TCPIP*:;") + .append(objectName).append('*') + .append(formatTimestamp(fix.getWallTimeMillis())) + .append(formatLatitude(fix.getLatitude())) + .append(symbolTable) + .append(formatLongitude(fix.getLongitude())) + .append(symbolCode); + appendMovementAndComment(packet, fix, comment); + return packet.toString(); + } + + private static void appendMovementAndComment(StringBuilder packet, TrackingFix fix, + String comment) { + if (fix.hasSpeed() && fix.hasBearing() && fix.getSpeedMetersPerSecond() >= 0.5f) { + int course = Math.round(fix.getBearingDegrees()) % 360; + if (course == 0) { + course = 360; + } + int knots = Math.min(999, Math.max(0, + (int) Math.round(fix.getSpeedMetersPerSecond() + * METERS_PER_SECOND_TO_KNOTS))); + packet.append(String.format(Locale.ROOT, "%03d/%03d", course, knots)); + } + String safeComment = sanitizeComment(comment); + if (!safeComment.isEmpty()) { + packet.append(safeComment); + } + } + + private static void validatePosition(TrackingFix fix, char symbolTable, char symbolCode) { + if (fix == null || !fix.hasValidCoordinates()) { + throw new IllegalArgumentException("invalid APRS position"); + } + if (symbolTable < 0x21 || symbolTable > 0x7e + || symbolCode < 0x21 || symbolCode > 0x7e) { + throw new IllegalArgumentException("invalid APRS symbol"); + } + } + + static String formatTimestamp(long wallTimeMillis) { + Calendar utc = new GregorianCalendar(TimeZone.getTimeZone("UTC"), Locale.ROOT); + utc.setTimeInMillis(wallTimeMillis); + return String.format(Locale.ROOT, "%02d%02d%02dz", + utc.get(Calendar.DAY_OF_MONTH), utc.get(Calendar.HOUR_OF_DAY), + utc.get(Calendar.MINUTE)); + } + + static String formatLatitude(double latitude) { + return formatCoordinate(latitude, 90, 2, latitude < 0.0 ? 'S' : 'N'); + } + + static String formatLongitude(double longitude) { + return formatCoordinate(longitude, 180, 3, longitude < 0.0 ? 'W' : 'E'); + } + + private static String formatCoordinate(double coordinate, int maximumDegrees, + int degreeDigits, char hemisphere) { + double absolute = Math.abs(coordinate); + int degrees = (int) Math.floor(absolute); + double minutes = (absolute - degrees) * 60.0; + minutes = Math.round(minutes * 100.0) / 100.0; + if (minutes >= 60.0) { + minutes = 0.0; + degrees++; + } + if (degrees > maximumDegrees) { + throw new IllegalArgumentException("coordinate out of range"); + } + return String.format(Locale.ROOT, "%0" + degreeDigits + "d%05.2f%c", + degrees, minutes, hemisphere); + } + + private static String normalizeCallsign(String value) { + String callsign = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!callsign.matches("[A-Z0-9]{3,6}(-[A-Z0-9]{1,2})?") + || callsign.endsWith("-0")) { + throw new IllegalArgumentException("invalid APRS source callsign"); + } + return callsign; + } + + private static String sanitizeComment(String comment) { + if (comment == null) { + return ""; + } + StringBuilder result = new StringBuilder(Math.min(40, comment.length())); + for (int index = 0; index < comment.length() && result.length() < 40; index++) { + char character = comment.charAt(index); + if (character >= 0x20 && character <= 0x7e && character != '|' + && character != '~') { + result.append(character); + } + } + return result.toString().trim(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingConfig.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingConfig.java new file mode 100644 index 00000000..c9e80036 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingConfig.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Locale; + +import se.lublin.mumla.radio.RadioDeviceProfile; + +/** Minimal validated configuration for T56-only APRS tracking. */ +public final class AprsTrackingConfig { + public static final String DEFAULT_HOST = "ametx.com"; + public static final int DEFAULT_PORT = 8888; + + private final boolean enabled; + private final boolean aprsEnabled; + private final boolean pttTriggered; + private final String sourceCallsign; + private final String passcode; + private final String host; + private final int port; + private final String objectName; + + private AprsTrackingConfig(boolean enabled, boolean aprsEnabled, boolean pttTriggered, + String sourceCallsign, String passcode, String host, int port, + String objectName) { + this.enabled = enabled; + this.aprsEnabled = aprsEnabled; + this.pttTriggered = pttTriggered; + this.sourceCallsign = sourceCallsign; + this.passcode = passcode; + this.host = host; + this.port = port; + this.objectName = objectName; + } + + public static AprsTrackingConfig disabled() { + return new AprsTrackingConfig(false, false, false, "", "", DEFAULT_HOST, DEFAULT_PORT, + ""); + } + + public static AprsTrackingConfig fromJson(JSONObject root, String hardwareProfile) + throws JSONException { + if (!RadioDeviceProfile.supportsLocationTracking(hardwareProfile)) { + return disabled(); + } + JSONObject tracking = root == null ? null : root.optJSONObject("tracking"); + if (tracking == null || !tracking.optBoolean("enabled", false)) { + return disabled(); + } + JSONObject aprs = tracking.optJSONObject("aprs"); + boolean aprsEnabled = aprs != null && aprs.optBoolean("enabled", false); + if (!aprsEnabled) { + return new AprsTrackingConfig(true, false, + tracking.optBoolean("pttTriggered", true), "", "", + DEFAULT_HOST, DEFAULT_PORT, ""); + } + + String callsign = requireString(aprs, "sourceCallsign").toUpperCase(Locale.ROOT); + if (!callsign.matches("[A-Z0-9]{3,6}(-[A-Z0-9]{1,2})?") + || callsign.endsWith("-0")) { + throw new JSONException("invalid APRS source callsign"); + } + String passcode = requireString(aprs, "passcode"); + if (!passcode.matches("[0-9]{1,5}")) { + throw new JSONException("invalid APRS passcode"); + } + int passcodeNumber; + try { + passcodeNumber = Integer.parseInt(passcode); + } catch (NumberFormatException exception) { + throw new JSONException("invalid APRS passcode"); + } + if (passcodeNumber < 0 || passcodeNumber > 32767) { + throw new JSONException("invalid APRS passcode"); + } + String host = aprs.optString("host", DEFAULT_HOST).trim().toLowerCase(Locale.ROOT); + if (host.isEmpty() || host.length() > 253 || !host.matches("[a-z0-9.-]+")) { + throw new JSONException("invalid APRS-IS host"); + } + int port = aprs.optInt("port", DEFAULT_PORT); + if (port < 1 || port > 65535) { + throw new JSONException("invalid APRS-IS port"); + } + String objectName = ""; + if (aprs.has("objectName")) { + if (!(aprs.opt("objectName") instanceof String)) { + throw new JSONException("invalid APRS Object name"); + } + try { + objectName = AprsObjectName.fromConfiguredName(aprs.optString("objectName")); + } catch (IllegalArgumentException exception) { + throw new JSONException(exception.getMessage()); + } + } + return new AprsTrackingConfig(true, true, + tracking.optBoolean("pttTriggered", true), callsign, passcode, host, port, + objectName); + } + + private static String requireString(JSONObject object, String key) throws JSONException { + if (object == null || !(object.opt(key) instanceof String)) { + throw new JSONException("missing " + key); + } + String value = object.optString(key, "").trim(); + if (value.isEmpty()) { + throw new JSONException("empty " + key); + } + return value; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isAprsEnabled() { + return aprsEnabled; + } + + public boolean isPttTriggered() { + return pttTriggered; + } + + public String getSourceCallsign() { + return sourceCallsign; + } + + public String getPasscode() { + return passcode; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + /** Returns the normalized nine-character name, or an empty string for Device ID fallback. */ + public String getObjectName() { + return objectName; + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java new file mode 100644 index 00000000..2d956f92 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java @@ -0,0 +1,565 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.SystemClock; +import android.telephony.PhoneStateListener; +import android.telephony.SignalStrength; +import android.telephony.TelephonyManager; +import android.util.Log; + +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +import se.lublin.mumla.radio.DeviceIdentityManager; +import se.lublin.mumla.radio.RadioDeviceProfile; +import se.lublin.mumla.service.MumlaService; + +/** T56-only adaptive location and APRS-IS coordinator. */ +public final class AprsTrackingManager { + // Android 5.1 rejects log tags longer than 23 characters. + private static final String TAG = "MinimumAprs"; + private static final int POLL_REQUEST_CODE = 5601; + private static final long STATIONARY_POLL_MS = 30L * 60L * 1000L; + private static final long STATIONARY_ACQUISITION_WINDOW_MS = 90L * 1000L; + private static final long MOVING_POLL_MS = 5L * 60L * 1000L; + private static final char APRS_SYMBOL_TABLE = '/'; + private static final char STATIONARY_SYMBOL_CODE = '-'; + private static final char WALKING_SYMBOL_CODE = '['; + private static final char VEHICLE_SYMBOL_CODE = '>'; + private static final String PREF_LAST_LATITUDE = "tracking_last_latitude"; + private static final String PREF_LAST_LONGITUDE = "tracking_last_longitude"; + private static final String PREF_LAST_ACCURACY = "tracking_last_accuracy"; + private static final String PREF_LAST_WALL = "tracking_last_wall"; + private static final String PREF_LAST_STATE = "tracking_last_state"; + private static final String PREF_LAST_SUCCESS_WALL = "tracking_last_success_wall"; + private static final String PREF_LAST_OBJECT_NAME = "tracking_last_object_name"; + private static final String PREF_PACKET_FORMAT = "tracking_packet_format"; + private static final int POSITION_PACKET_FORMAT_VERSION = 5; + + private final Context context; + private final LocationManager locationManager; + private final AlarmManager alarmManager; + private final HandlerThread locationThread; + private final Handler handler; + private final ExecutorService transportExecutor; + private final AprsTransport transport; + private final String defaultObjectName; + private volatile String objectName; + private final PhoneStateListener phoneStateListener = new PhoneStateListener() { + @Override + public void onSignalStrengthsChanged(SignalStrength signalStrength) { + try { + int dbm = signalStrength == null ? AprsHealthSnapshot.UNKNOWN + : signalStrengthDbm(signalStrength); + mobileRssiDbm = dbm == 0 || dbm == Integer.MAX_VALUE + ? AprsHealthSnapshot.UNKNOWN : dbm; + } catch (RuntimeException ignored) { + mobileRssiDbm = AprsHealthSnapshot.UNKNOWN; + } + } + }; + private final AprsBeaconCoordinator coordinator = new AprsBeaconCoordinator(); + private final Runnable stationaryAcquisitionTimeout = this::stopLocationUpdates; + private final LocationListener listener = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + handleLocation(location); + } + + @Override + public void onProviderDisabled(String provider) { + Log.w(TAG, "location provider disabled: " + provider); + } + + @Override + public void onProviderEnabled(String provider) { + Log.d(TAG, "location provider enabled: " + provider); + } + + @Override + public void onStatusChanged(String provider, int status, android.os.Bundle extras) { + // API-22 callback retained for the T56 platform. + } + }; + + private volatile AprsTrackingConfig config = AprsTrackingConfig.disabled(); + private volatile boolean stopped; + private volatile int mobileRssiDbm = AprsHealthSnapshot.UNKNOWN; + private TelephonyManager telephonyManager; + private AprsBeaconCoordinator.MovementState requestedState; + + public AprsTrackingManager(Context context) { + this(context, new AprsIsHttpTransport(context)); + } + + AprsTrackingManager(Context context, AprsTransport transport) { + if (context == null || transport == null) { + throw new IllegalArgumentException("tracking dependencies must not be null"); + } + this.context = context.getApplicationContext(); + this.transport = transport; + this.defaultObjectName = AprsObjectName.fromDeviceId(new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(this.context)).getOrCreateDeviceId()); + this.objectName = defaultObjectName; + this.locationManager = (LocationManager) this.context + .getSystemService(Context.LOCATION_SERVICE); + this.alarmManager = (AlarmManager) this.context + .getSystemService(Context.ALARM_SERVICE); + this.locationThread = new HandlerThread("minimum-t56-location"); + this.locationThread.start(); + this.handler = new Handler(locationThread.getLooper()); + this.transportExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "minimum-t56-aprs"); + thread.setDaemon(true); + return thread; + }); + restoreLastSuccessful(); + } + + public void reloadConfig(JSONObject root) { + String hardwareProfile = RadioDeviceProfile.detectCurrent(); + if (!RadioDeviceProfile.T56.equals(hardwareProfile)) { + return; + } + final AprsTrackingConfig loaded = parseConfigOrDisabled(root, hardwareProfile, true); + if (!loaded.isEnabled() || !loaded.isAprsEnabled()) { + // Flip the in-memory gate before scheduling listener/alarm cleanup so concurrent + // PTT/location callbacks fail closed while the handler drains its queue. + config = loaded; + } + handler.post(() -> { + String nextObjectName = loaded.getObjectName().isEmpty() + ? defaultObjectName : loaded.getObjectName(); + if (!nextObjectName.equals(objectName)) { + objectName = nextObjectName; + coordinator.resetForObjectIdentity(); + clearPersistedSuccess(); + } + config = loaded; + if (!config.isEnabled() || !config.isAprsEnabled()) { + stopLocationUpdates(); + stopMobileSignalListener(); + cancelPoll(); + clearPersistedSuccess(); + Log.i(TAG, "T56 tracking is configured off"); + return; + } + if (!hasLocationPermission()) { + Log.w(TAG, "T56 tracking disabled until location permission is granted"); + return; + } + startMobileSignalListener(); + requestLocationUpdates(); + sendReady(); + }); + } + + /** + * Parses a tracking section using the same fail-closed policy as {@link #reloadConfig}. + * Package-private visibility keeps this small policy independently unit-testable without + * constructing Android location/telephony services in a JVM test. + */ + static AprsTrackingConfig parseConfigOrDisabled(JSONObject root, String hardwareProfile) { + return parseConfigOrDisabled(root, hardwareProfile, false); + } + + private static AprsTrackingConfig parseConfigOrDisabled(JSONObject root, String hardwareProfile, + boolean logRejection) { + try { + return AprsTrackingConfig.fromJson(root, hardwareProfile); + } catch (JSONException exception) { + if (logRejection) { + Log.w(TAG, "tracking config rejected: " + exception.getMessage()); + } + return AprsTrackingConfig.disabled(); + } + } + + public void onPoll() { + if (!RadioDeviceProfile.T56.equals(RadioDeviceProfile.detectCurrent())) { + return; + } + handler.post(() -> { + if (!config.isEnabled() || !config.isAprsEnabled() || stopped) { + return; + } + requestLocationUpdates(); + sendReady(); + }); + } + + public void onPttPressed() { + if (!config.isEnabled() || !config.isAprsEnabled() || !config.isPttTriggered()) { + return; + } + // This only evaluates the cached accepted fix; it never waits for or starts a GPS fix. + handler.post(() -> { + AprsBeaconCoordinator.Decision decision = coordinator.onPtt( + System.currentTimeMillis(), SystemClock.elapsedRealtime()); + logDecision("PTT", decision); + sendReady(); + }); + } + + public void stop() { + if (stopped) { + return; + } + stopped = true; + handler.post(() -> { + stopLocationUpdates(); + stopMobileSignalListener(); + cancelPoll(); + locationThread.quitSafely(); + }); + transportExecutor.shutdownNow(); + } + + private void handleLocation(Location location) { + if (stopped || location == null || !config.isEnabled() || !config.isAprsEnabled()) { + return; + } + long elapsed = location.getElapsedRealtimeNanos() > 0L + ? location.getElapsedRealtimeNanos() / 1_000_000L + : SystemClock.elapsedRealtime(); + TrackingFix fix = new TrackingFix(location.getLatitude(), location.getLongitude(), + location.hasAccuracy() ? location.getAccuracy() : TrackingFix.UNKNOWN, + location.getTime(), elapsed, + location.hasSpeed() ? location.getSpeed() : TrackingFix.UNKNOWN, + location.hasBearing() ? location.getBearing() : TrackingFix.UNKNOWN); + AprsBeaconCoordinator.Decision decision = coordinator.onLocation(fix, + System.currentTimeMillis(), SystemClock.elapsedRealtime()); + logDecision("GPS", decision); + AprsBeaconCoordinator.MovementState state = coordinator.getMovementState(); + if (state != requestedState) { + requestedState = state; + requestLocationUpdates(); + } + sendReady(); + } + + private void sendReady() { + if (stopped || !config.isAprsEnabled()) { + return; + } + AprsBeaconCoordinator.Beacon beacon = coordinator.takeReady(SystemClock.elapsedRealtime()); + if (beacon == null) { + scheduleRetryIfNeeded(); + return; + } + final AprsTrackingConfig packetConfig = config; + final String packetObjectName = objectName; + final String packet; + try { + String healthComment = AprsHealthSnapshot.capture(context, mobileRssiDbm) + .toAprsComment(beacon.getMovementState(), beacon.getFix().getAccuracyMeters()); + packet = AprsPacketEncoder.encodeObject(packetConfig.getSourceCallsign(), packetObjectName, + beacon.getFix(), APRS_SYMBOL_TABLE, + symbolCodeFor(beacon.getMovementState()), healthComment); + } catch (RuntimeException exception) { + Log.w(TAG, "APRS packet rejected before transport: " + exception.getClass().getSimpleName()); + coordinator.onSendFailure(beacon.getLogicalId(), false, SystemClock.elapsedRealtime()); + scheduleRetryIfNeeded(); + return; + } + try { + transportExecutor.execute(() -> { + AprsTransport.SendResult result = transport.send(packetConfig, packet); + handler.post(() -> { + long now = SystemClock.elapsedRealtime(); + if (result.getStatus() == AprsTransport.SendResult.Status.SUCCESS) { + boolean applied = coordinator.onSendSuccess(beacon.getLogicalId(), now); + if (applied && packetObjectName.equals(objectName)) { + persistSuccess(beacon, now, packetObjectName); + Log.i(TAG, "APRS position accepted by send-only server"); + } else { + Log.i(TAG, "APRS receipt ignored after Object identity changed"); + } + } else if (result.getStatus() + == AprsTransport.SendResult.Status.PERMANENT_FAILURE) { + coordinator.onPermanentFailure(beacon.getLogicalId()); + Log.w(TAG, "APRS send disabled until configuration changes: " + + result.getDetail()); + } else { + coordinator.onSendFailure(beacon.getLogicalId(), + result.getStatus() + == AprsTransport.SendResult.Status.UNCERTAIN_DELIVERY, + now); + Log.w(TAG, "APRS send failed: " + result.getDetail()); + } + scheduleRetryIfNeeded(); + }); + }); + } catch (RejectedExecutionException ignored) { + coordinator.onSendFailure(beacon.getLogicalId(), false, + SystemClock.elapsedRealtime()); + } + } + + private static char symbolCodeFor(AprsBeaconCoordinator.MovementState state) { + if (state == AprsBeaconCoordinator.MovementState.VEHICLE) { + return VEHICLE_SYMBOL_CODE; + } + if (state == AprsBeaconCoordinator.MovementState.WALKING) { + return WALKING_SYMBOL_CODE; + } + return STATIONARY_SYMBOL_CODE; + } + + private void scheduleRetryIfNeeded() { + long nextAttempt = coordinator.getNextAttemptElapsedRealtime(); + if (nextAttempt < 0L) { + return; + } + long delay = Math.max(1_000L, nextAttempt - SystemClock.elapsedRealtime()); + handler.postDelayed(this::sendReady, delay); + } + + private void requestLocationUpdates() { + if (!hasLocationPermission() || locationManager == null || stopped + || !config.isEnabled() || !config.isAprsEnabled()) { + return; + } + handler.removeCallbacks(stationaryAcquisitionTimeout); + stopLocationUpdates(); + AprsBeaconCoordinator.MovementState state = coordinator.getMovementState(); + requestedState = state; + try { + if (state == AprsBeaconCoordinator.MovementState.STATIONARY) { + if (isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, + 15L * 60L * 1000L, 100.0f, listener, handler.getLooper()); + } + if (isProviderEnabled(LocationManager.GPS_PROVIDER)) { + // T56 firmware does not reliably deliver callbacks from requestSingleUpdate. + // Use a short-lived regular request; stationaryAcquisitionTimeout removes it + // after the bounded 90-second GPS window. + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, + 1_000L, 0.0f, listener, handler.getLooper()); + } + handler.postDelayed(stationaryAcquisitionTimeout, + STATIONARY_ACQUISITION_WINDOW_MS); + schedulePoll(STATIONARY_POLL_MS); + } else if (state == AprsBeaconCoordinator.MovementState.WALKING) { + if (isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, + 20_000L, 15.0f, listener, handler.getLooper()); + } + schedulePoll(MOVING_POLL_MS); + } else { + if (isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, + 8_000L, 20.0f, listener, handler.getLooper()); + } + schedulePoll(MOVING_POLL_MS); + } + } catch (SecurityException exception) { + Log.w(TAG, "T56 location request denied by platform"); + } catch (IllegalArgumentException exception) { + Log.w(TAG, "T56 location provider unavailable"); + } + } + + private boolean isProviderEnabled(String provider) { + try { + return locationManager != null && locationManager.isProviderEnabled(provider); + } catch (RuntimeException ignored) { + return false; + } + } + + private boolean hasLocationPermission() { + return Build.VERSION.SDK_INT < 23 + || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private void stopLocationUpdates() { + if (locationManager != null) { + try { + locationManager.removeUpdates(listener); + } catch (SecurityException ignored) { + // Nothing to release when the permission was revoked. + } + } + } + + private void startMobileSignalListener() { + if (telephonyManager == null) { + telephonyManager = (TelephonyManager) context.getSystemService( + Context.TELEPHONY_SERVICE); + } + if (telephonyManager == null) { + return; + } + try { + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS); + } catch (SecurityException ignored) { + mobileRssiDbm = AprsHealthSnapshot.UNKNOWN; + } + } + + private static int signalStrengthDbm(SignalStrength signalStrength) { + try { + java.lang.reflect.Method method = SignalStrength.class.getMethod("getDbm"); + Object value = method.invoke(signalStrength); + if (value instanceof Integer) { + return (Integer) value; + } + } catch (Exception ignored) { + // API-22 has no public getDbm method on all vendor builds. + } + try { + int asu = signalStrength.getGsmSignalStrength(); + return asu >= 0 && asu < 32 ? -113 + (2 * asu) : AprsHealthSnapshot.UNKNOWN; + } catch (RuntimeException ignored) { + return AprsHealthSnapshot.UNKNOWN; + } + } + + private void stopMobileSignalListener() { + if (telephonyManager == null) { + return; + } + try { + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); + } catch (SecurityException ignored) { + // Nothing to unregister when phone state permission was revoked. + } + } + + private void schedulePoll(long delayMillis) { + if (alarmManager == null) { + return; + } + PendingIntent pendingIntent = pollIntent(); + long triggerAt = SystemClock.elapsedRealtime() + delayMillis; + if (Build.VERSION.SDK_INT >= 19) { + alarmManager.setWindow(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, + Math.min(5L * 60L * 1000L, Math.max(60_000L, delayMillis / 3)), + pendingIntent); + } else { + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAt, pendingIntent); + } + } + + private void cancelPoll() { + if (alarmManager != null) { + alarmManager.cancel(pollIntent()); + } + } + + private PendingIntent pollIntent() { + Intent intent = new Intent(context, MumlaService.class) + .setAction(MumlaService.ACTION_RADIO_TRACKING_POLL); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 23) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getService(context, POLL_REQUEST_CODE, intent, flags); + } + + private void restoreLastSuccessful() { + android.content.SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(context); + if (preferences.getInt(PREF_PACKET_FORMAT, 0) != POSITION_PACKET_FORMAT_VERSION) { + return; + } + if (!preferences.contains(PREF_LAST_LATITUDE) + || !preferences.contains(PREF_LAST_LONGITUDE)) { + return; + } + long wall = preferences.getLong(PREF_LAST_WALL, 0L); + long successWall = preferences.getLong(PREF_LAST_SUCCESS_WALL, wall); + long age = successWall <= 0L ? Long.MAX_VALUE + : Math.max(0L, System.currentTimeMillis() - successWall); + if (age > 24L * 60L * 60L * 1000L) { + return; + } + long elapsed = Math.max(1L, SystemClock.elapsedRealtime() - age); + TrackingFix fix = new TrackingFix( + Double.longBitsToDouble(preferences.getLong(PREF_LAST_LATITUDE, 0L)), + Double.longBitsToDouble(preferences.getLong(PREF_LAST_LONGITUDE, 0L)), + preferences.getFloat(PREF_LAST_ACCURACY, TrackingFix.UNKNOWN), + wall, elapsed, TrackingFix.UNKNOWN, TrackingFix.UNKNOWN); + AprsBeaconCoordinator.MovementState state; + try { + state = AprsBeaconCoordinator.MovementState.valueOf( + preferences.getString(PREF_LAST_STATE, + AprsBeaconCoordinator.MovementState.STATIONARY.name())); + } catch (IllegalArgumentException exception) { + state = AprsBeaconCoordinator.MovementState.STATIONARY; + } + coordinator.restoreLastSuccessful(fix, state, elapsed); + String persistedObjectName = preferences.getString(PREF_LAST_OBJECT_NAME, ""); + if (persistedObjectName.length() == AprsObjectName.APRS_OBJECT_NAME_LENGTH) { + objectName = persistedObjectName; + } + Log.i(TAG, "restored previously accepted APRS position"); + } + + private void persistSuccess(AprsBeaconCoordinator.Beacon beacon, long nowElapsed, + String packetObjectName) { + TrackingFix fix = beacon.getFix(); + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(PREF_LAST_LATITUDE, Double.doubleToRawLongBits(fix.getLatitude())) + .putLong(PREF_LAST_LONGITUDE, Double.doubleToRawLongBits(fix.getLongitude())) + .putFloat(PREF_LAST_ACCURACY, fix.getAccuracyMeters()) + .putLong(PREF_LAST_WALL, fix.getWallTimeMillis()) + .putLong(PREF_LAST_SUCCESS_WALL, System.currentTimeMillis()) + .putString(PREF_LAST_OBJECT_NAME, packetObjectName) + .putString(PREF_LAST_STATE, beacon.getMovementState().name()) + .putInt(PREF_PACKET_FORMAT, POSITION_PACKET_FORMAT_VERSION) + .apply(); + } + + private void clearPersistedSuccess() { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .remove(PREF_LAST_LATITUDE) + .remove(PREF_LAST_LONGITUDE) + .remove(PREF_LAST_ACCURACY) + .remove(PREF_LAST_WALL) + .remove(PREF_LAST_SUCCESS_WALL) + .remove(PREF_LAST_OBJECT_NAME) + .remove(PREF_LAST_STATE) + .remove(PREF_PACKET_FORMAT) + .apply(); + } + + private static void logDecision(String source, AprsBeaconCoordinator.Decision decision) { + Log.d(TAG, source + " location=" + decision.isLocationAccepted() + + " beacon=" + decision.isBeaconQueued() + + " state=" + decision.getMovementState() + + " reason=" + decision.getTriggerReason() + + " detail=" + decision.getDetail()); + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTransport.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTransport.java new file mode 100644 index 00000000..b75a643f --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTransport.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +/** Modular send-only APRS transport boundary. */ +public interface AprsTransport { + SendResult send(AprsTrackingConfig config, String packet); + + final class SendResult { + public enum Status { + SUCCESS, + RETRYABLE_FAILURE, + UNCERTAIN_DELIVERY, + PERMANENT_FAILURE + } + + private final Status status; + private final String detail; + + private SendResult(Status status, String detail) { + this.status = status; + this.detail = detail; + } + + public static SendResult success() { + return new SendResult(Status.SUCCESS, "sent"); + } + + public static SendResult retryable(String detail) { + return new SendResult(Status.RETRYABLE_FAILURE, detail); + } + + public static SendResult uncertain(String detail) { + return new SendResult(Status.UNCERTAIN_DELIVERY, detail); + } + + public static SendResult permanent(String detail) { + return new SendResult(Status.PERMANENT_FAILURE, detail); + } + + public Status getStatus() { + return status; + } + + public String getDetail() { + return detail; + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingFix.java b/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingFix.java new file mode 100644 index 00000000..6f443bfa --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/radio/tracking/TrackingFix.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 The Mumla contributors + * + * 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. + */ + +package se.lublin.mumla.radio.tracking; + +/** Immutable location sample used by the platform-independent tracking pipeline. */ +public final class TrackingFix { + public static final float UNKNOWN = -1.0f; + + private final double latitude; + private final double longitude; + private final float accuracyMeters; + private final long wallTimeMillis; + private final long elapsedRealtimeMillis; + private final float speedMetersPerSecond; + private final float bearingDegrees; + + public TrackingFix(double latitude, double longitude, float accuracyMeters, + long wallTimeMillis, long elapsedRealtimeMillis, + float speedMetersPerSecond, float bearingDegrees) { + this.latitude = latitude; + this.longitude = longitude; + this.accuracyMeters = accuracyMeters; + this.wallTimeMillis = wallTimeMillis; + this.elapsedRealtimeMillis = elapsedRealtimeMillis; + this.speedMetersPerSecond = speedMetersPerSecond; + this.bearingDegrees = bearingDegrees; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public float getAccuracyMeters() { + return accuracyMeters; + } + + public long getWallTimeMillis() { + return wallTimeMillis; + } + + public long getElapsedRealtimeMillis() { + return elapsedRealtimeMillis; + } + + public float getSpeedMetersPerSecond() { + return speedMetersPerSecond; + } + + public float getBearingDegrees() { + return bearingDegrees; + } + + public boolean hasSpeed() { + return Float.isFinite(speedMetersPerSecond) && speedMetersPerSecond >= 0.0f; + } + + public boolean hasBearing() { + return Float.isFinite(bearingDegrees) && bearingDegrees >= 0.0f + && bearingDegrees <= 360.0f; + } + + public boolean hasValidCoordinates() { + return Double.isFinite(latitude) && Double.isFinite(longitude) + && latitude >= -90.0 && latitude <= 90.0 + && longitude >= -180.0 && longitude <= 180.0; + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/FavouriteServerAdapter.java b/app/src/main/java/se/lublin/mumla/servers/FavouriteServerAdapter.java new file mode 100644 index 00000000..3660cf39 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/FavouriteServerAdapter.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.content.Context; +import android.view.MenuItem; + +import java.util.List; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; + +/** + * Created by andrew on 11/05/14. + */ +public class FavouriteServerAdapter extends ServerAdapter { + + private FavouriteServerAdapterMenuListener mListener; + + public FavouriteServerAdapter(Context context, List servers, FavouriteServerAdapterMenuListener listener) { + super(context, R.layout.server_list_row, servers); + mListener = listener; + } + + @Override + public int getPopupMenuResource() { + return R.menu.popup_favourite_server; + } + + @Override + public boolean onPopupItemClick(Server server, MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.menu_server_edit) { + mListener.editServer(server); + return true; + } else if (itemId == R.id.menu_server_share) { + mListener.shareServer(server); + return true; + } else if (itemId == R.id.menu_server_delete) { + mListener.deleteServer(server); + return true; + } + return false; + } + + public static interface FavouriteServerAdapterMenuListener { + public void editServer(Server server); + public void shareServer(Server server); + public void deleteServer(Server server); + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/FavouriteServerListFragment.java b/app/src/main/java/se/lublin/mumla/servers/FavouriteServerListFragment.java new file mode 100644 index 00000000..1eda45d7 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/FavouriteServerListFragment.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.GridView; + +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.List; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; +import se.lublin.mumla.db.DatabaseProvider; +import se.lublin.mumla.db.PublicServer; + +/** + * Displays a list of servers, and allows the user to connect and edit them. + * @author morlunk + * + */ +public class FavouriteServerListFragment extends Fragment implements OnItemClickListener, FavouriteServerAdapter.FavouriteServerAdapterMenuListener { + + private ServerConnectHandler mConnectHandler; + private DatabaseProvider mDatabaseProvider; + private GridView mServerGrid; + private ServerAdapter mServerAdapter; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mConnectHandler = (ServerConnectHandler)activity; + mDatabaseProvider = (DatabaseProvider) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString()+" must implement ServerConnectHandler!"); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_server_list, container, false); + mServerGrid = (GridView) view.findViewById(R.id.server_list_grid); + mServerGrid.setOnItemClickListener(this); + mServerGrid.setEmptyView(view.findViewById(R.id.server_list_grid_empty)); + + registerForContextMenu(mServerGrid); + return view; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.fragment_server_list, menu); + } + + @Override + public void onResume() { + super.onResume(); + updateServers(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_add_server_item) { + addServer(); + return true; + } else if (itemId == R.id.menu_quick_connect) { + ServerEditFragment.createServerEditDialog(getActivity(), null, ServerEditFragment.Action.CONNECT_ACTION, true) + .show(getFragmentManager(), "serverInfo"); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void addServer() { + ServerEditFragment.createServerEditDialog(getActivity(), null, ServerEditFragment.Action.ADD_ACTION, false) + .show(getFragmentManager(), "serverInfo"); + } + + public void editServer(Server server) { + ServerEditFragment.createServerEditDialog(getActivity(), server, ServerEditFragment.Action.EDIT_ACTION, false) + .show(getFragmentManager(), "serverInfo"); + } + + public void shareServer(Server server) { + // Build Mumble server URL + String serverUrl = "mumble://" + server.getHost() + + (server.getPort() == 0 ? "" : ":" + server.getPort()) + "/"; + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.shareMessage, serverUrl)); + intent.setType("text/plain"); + startActivity(intent); + } + + public void deleteServer(final Server server) { + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.confirm_delete_server) + .setPositiveButton(R.string.delete, (dialog, which) -> { + mDatabaseProvider.getDatabase().removeServer(server); + mServerAdapter.remove(server); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + public void updateServers() { + List servers = getServers(); + mServerAdapter = new FavouriteServerAdapter(getActivity(), servers, this); + mServerGrid.setAdapter(mServerAdapter); + } + + + + public List getServers() { + List servers = mDatabaseProvider.getDatabase().getServers(); + return servers; + } + + @Override + public void onItemClick(AdapterView arg0, View arg1, int arg2, long arg3) { + mConnectHandler.connectToServer(mServerAdapter.getItem(arg2)); + } + + public static interface ServerConnectHandler { + public void connectToServer(Server server); + public void connectToPublicServer(PublicServer server); + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/PublicServerAdapter.java b/app/src/main/java/se/lublin/mumla/servers/PublicServerAdapter.java new file mode 100644 index 00000000..25a7bc2a --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/PublicServerAdapter.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.content.Context; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; +import se.lublin.mumla.db.PublicServer; + +/** +* Created by andrew on 07/05/14. +*/ +public class PublicServerAdapter extends ServerAdapter { + private List mUnfilteredServers; + private PublicServerAdapterMenuListener mListener; + + public PublicServerAdapter(Context context, List servers, PublicServerAdapterMenuListener listener) { + super(context, R.layout.public_server_list_row, servers); + mUnfilteredServers = new ArrayList(servers); + mListener = listener; + } + + public void filter(String queryName, String queryCountry) { + clear(); + + for(PublicServer server : mUnfilteredServers) { + String serverName = server.getName() != null ? server.getName().toUpperCase(Locale.US) : ""; + String serverCountry = server.getCountry() != null ? server.getCountry().toUpperCase(Locale.US) : ""; + + if(serverName.contains(queryName) && serverCountry.contains(queryCountry)) + add(server); + } + } + + @Override + public View getView(int position, View v, ViewGroup parent) { + View view = super.getView(position, v, parent); + + final PublicServer server = getItem(position); + + TextView locationText = (TextView) view.findViewById(R.id.server_row_location); + locationText.setText(server.getCountry()); + + return view; + } + + @Override + public int getPopupMenuResource() { + return R.menu.popup_public_server; + } + + @Override + public boolean onPopupItemClick(Server server, MenuItem menuItem) { + if (menuItem.getItemId() == R.id.menu_server_favourite) { + mListener.favouriteServer(server); + return true; + } + return false; + } + + public static interface PublicServerAdapterMenuListener { + public void favouriteServer(Server server); + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/PublicServerFetchTask.java b/app/src/main/java/se/lublin/mumla/servers/PublicServerFetchTask.java new file mode 100644 index 00000000..24c6c240 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/PublicServerFetchTask.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.content.Context; +import android.os.AsyncTask; +import android.util.Xml; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import se.lublin.mumla.db.PublicServer; + +/** + * Created by andrew on 05/05/14. + */ +class PublicServerFetchTask extends AsyncTask> { + protected WeakReference contextRef; + private static final String MUMBLE_PUBLIC_URL = "https://mumble.info/list2.cgi"; + + public PublicServerFetchTask(Context context) { + contextRef = new WeakReference<>(context); + } + + @Override + protected List doInBackground(Void... params) { + try { + // Fetch XML from server + URL url = new URL(MUMBLE_PUBLIC_URL); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.addRequestProperty("version", se.lublin.humla.Constants.PROTOCOL_STRING); + connection.connect(); + InputStream stream = connection.getInputStream(); + + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, "UTF-8"); + parser.nextTag(); + + List serverList = new ArrayList(); + + parser.require(XmlPullParser.START_TAG, null, "servers"); + while(parser.next() != XmlPullParser.END_TAG) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + serverList.add(readEntry(parser)); + } + parser.require(XmlPullParser.END_TAG, null, "servers"); + + return serverList; + } catch (XmlPullParserException e) { + e.printStackTrace(); + } catch (ProtocolException e) { + e.printStackTrace(); + } catch (MalformedURLException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } + + private PublicServer readEntry(XmlPullParser parser) throws XmlPullParserException, IOException { + String name = parser.getAttributeValue(null, "name"); + String ca = parser.getAttributeValue(null, "ca"); + String continentCode = parser.getAttributeValue(null, "continent_code"); + String country = parser.getAttributeValue(null, "country"); + String countryCode = parser.getAttributeValue(null, "country_code"); + String ip = parser.getAttributeValue(null, "ip"); + String port = parser.getAttributeValue(null, "port"); + String region = parser.getAttributeValue(null, "region"); + String url = parser.getAttributeValue(null, "url"); + + parser.nextTag(); + + PublicServer server = new PublicServer(name, ca, continentCode, country, countryCode, ip, Integer.parseInt(port), region, url); + + return server; + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/PublicServerListFragment.java b/app/src/main/java/se/lublin/mumla/servers/PublicServerListFragment.java new file mode 100644 index 00000000..5283612a --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/PublicServerListFragment.java @@ -0,0 +1,448 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.GridView; +import android.widget.ProgressBar; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.db.DatabaseProvider; +import se.lublin.mumla.db.MumlaDatabase; +import se.lublin.mumla.db.PublicServer; + +/** + * Displays a list of public servers that can be connected to, sorted, and favourited. + * @author morlunk + * + */ +public class PublicServerListFragment extends Fragment implements OnItemClickListener, PublicServerAdapter.PublicServerAdapterMenuListener { + + private FavouriteServerListFragment.ServerConnectHandler mConnectHandler; + private DatabaseProvider mDatabaseProvider; + private List mServers; + private GridView mServerGrid; + private ProgressBar mServerProgress; + private PublicServerAdapter mServerAdapter; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setHasOptionsMenu(true); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mConnectHandler = (FavouriteServerListFragment.ServerConnectHandler)activity; + mDatabaseProvider = (DatabaseProvider) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString()+" must implement ServerConnectHandler and DatabaseProvider!"); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + fillPublicList(); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_public_server_list, container, false); + mServerGrid = view.findViewById(R.id.server_list_grid); + mServerGrid.setOnItemClickListener(this); + if(mServerAdapter != null) + mServerGrid.setAdapter(mServerAdapter); + mServerProgress = view.findViewById(R.id.serverProgress); + mServerProgress.setVisibility(mServerAdapter == null ? View.VISIBLE : View.GONE); + return view; + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_match_server).setVisible(true); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.fragment_public_server_list, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (isFilled()) { + int itemId = item.getItemId(); + if (itemId == R.id.menu_match_server) { + showMatchDialog(); + } else if (itemId == R.id.menu_sort_server_item) { + showSortDialog(); + return true; + } else if (itemId == R.id.menu_search_server_item) { + showFilterDialog(); + return true; + } + } + return super.onOptionsItemSelected(item); + } + + @Override + public void favouriteServer(final Server server) { + final Settings settings = Settings.getInstance(requireActivity()); + final EditText usernameField = new EditText(requireActivity()); + usernameField.setHint(settings.getDefaultUsername()); + FrameLayout layout = new FrameLayout(requireActivity()); + layout.addView(usernameField); + int horizontalPadding = (int) getResources().getDimension(R.dimen.padding_medium); + layout.setPadding(horizontalPadding, 0, horizontalPadding, 0); + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.addFavorite) + .setView(layout) + .setPositiveButton(R.string.add, (dialog, which) -> { + if (usernameField.getText().length() > 0) { + server.setUsername(usernameField.getText().toString()); + } else { + server.setUsername(settings.getDefaultUsername()); + } + MumlaDatabase database = mDatabaseProvider.getDatabase(); + database.addServer(server); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + public void setServers(List servers) { + mServers = servers; + mServerProgress.setVisibility(View.GONE); + mServerAdapter = new PublicServerAdapter(getActivity(), servers, this); + mServerGrid.setAdapter(mServerAdapter); + } + + public boolean isFilled() { + return mServerAdapter != null; + } + + private void showMatchDialog() { + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.server_match) + .setMessage(R.string.server_match_description) + .setPositiveButton(R.string.search, (dialog, which) -> findOptimalServer()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void findOptimalServer() { + MatchServerTask matchServerTask = new MatchServerTask(); + matchServerTask.execute(Locale.getDefault().getCountry()); + } + + private void showSortDialog() { + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.sortBy) + .setItems(new String[]{getString(R.string.name), getString(R.string.country)}, new SortClickListener()) + .show(); + } + + private void showFilterDialog() { + View dialogView = ((LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE)).inflate(R.layout.dialog_server_search, null); + final EditText nameText = dialogView.findViewById(R.id.server_search_name); + final EditText countryText = dialogView.findViewById(R.id.server_search_country); + + final AlertDialog alertDialog = new MaterialAlertDialogBuilder(getActivity()) + .setTitle(R.string.search) + .setView(dialogView) + .setPositiveButton(R.string.search, (dialog, which) -> { + String queryName = nameText.getText().toString().toUpperCase(Locale.US); + String queryCountry = countryText.getText().toString().toUpperCase(Locale.US); + mServerAdapter.filter(queryName, queryCountry); + dialog.dismiss(); + }) + .create(); + + nameText.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + nameText.setOnEditorActionListener((v, actionId, event) -> { + String queryName = nameText.getText().toString().toUpperCase(Locale.US); + String queryCountry = countryText.getText().toString().toUpperCase(Locale.US); + mServerAdapter.filter(queryName, queryCountry); + alertDialog.dismiss(); + return true; + }); + + countryText.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + countryText.setOnEditorActionListener((v, actionId, event) -> { + String queryName = nameText.getText().toString().toUpperCase(Locale.US); + String queryCountry = countryText.getText().toString().toUpperCase(Locale.US); + mServerAdapter.filter(queryName, queryCountry); + alertDialog.dismiss(); + return true; + }); + + // Show keyboard automatically + nameText.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + alertDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } + }); + + alertDialog.show(); + } + + @Override + public void onItemClick(AdapterView arg0, View arg1, int arg2, long arg3) { + mConnectHandler.connectToPublicServer(mServerAdapter.getItem(arg2)); + } + + private void fillPublicList() { + new PublicServerFetchTask(getActivity()) { + protected void onPostExecute(List result) { + super.onPostExecute(result); + + if (result == null) { + Context context = contextRef.get(); + if (context != null) { + Toast.makeText(context, R.string.error_fetching_servers, Toast.LENGTH_SHORT).show(); + } + return; + } + + if(isVisible()) // Prevents NPEs when fragment is detached. + setServers(result); + }; + }.execute(); + } + + private class SortClickListener implements DialogInterface.OnClickListener { + + private static final int SORT_NAME = 0; + private static final int SORT_COUNTRY = 1; + + private Comparator nameComparator = new Comparator() { + @Override + public int compare(PublicServer lhs, PublicServer rhs) { + return lhs.getName().compareTo(rhs.getName()); + } + }; + + private Comparator countryComparator = new Comparator() { + @Override + public int compare(PublicServer lhs, PublicServer rhs) { + if(rhs.getCountry() == null) return -1; + else if(lhs.getCountry() == null) return 1; + return lhs.getCountry().compareTo(rhs.getCountry()); + } + }; + + + @Override + public void onClick(DialogInterface dialog, int which) { + ArrayAdapter arrayAdapter = mServerAdapter; + if(which == SORT_NAME) { + arrayAdapter.sort(nameComparator); + } else if(which == SORT_COUNTRY) { + arrayAdapter.sort(countryComparator); + } + } + } + + /** + * Finds an empty server in the user's country code with low latency. + * By default, it will show a ProgressDialog while it performs this and an AlertDialog allowing the user to connect. + */ + private class MatchServerTask extends AsyncTask { + + /** + * Query until we find up to SEARCH_RANGE empty servers that meet the zero user and location requirements. + * Once we have that, then we'll sort them based on latency. + * TODO make this a user-changeable option. + */ + public static final int SEARCH_RANGE = 20; + + private Comparator mLatencyComparator = new Comparator() { + @Override + public int compare(ServerInfoResponse lhs, ServerInfoResponse rhs) { + return lhs.getLatency() == rhs.getLatency() ? 0 : + lhs.getLatency() < rhs.getLatency() ? -1 : 1; + } + }; + + private class MatchServerInfoTask extends ServerInfoTask { + @Override + protected void onPostExecute(ServerInfoResponse serverInfoResponse) { + mResponseCount++; + if(serverInfoResponse == null) { + // TODO handle bad responses + } else if(serverInfoResponse.getCurrentUsers() == 0 && + serverInfoResponse.getVersion() == se.lublin.humla.Constants.PROTOCOL_VERSION) { + mGoodResponses.add(serverInfoResponse); + } + + // Once we have a good sample of results, shut down the ping threads. + if(mResponseCount >= mResponsesToSend) { + synchronized (mLock) { + mLock.notify(); + } + mPingExecutor.shutdownNow(); + } + } + + } + + private ExecutorService mPingExecutor = Executors.newFixedThreadPool(5); // Run 5 concurrent pings at a time + private Object mLock = new Object(); + + private List mGoodResponses = Collections.synchronizedList(new ArrayList()); + private volatile int mResponseCount = 0; + private int mResponsesToSend = SEARCH_RANGE; + + private AlertDialog mProgressDialog; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + mProgressDialog = new MaterialAlertDialogBuilder(requireActivity()) + .setMessage(R.string.server_match_progress) + .setCancelable(true) + .setOnCancelListener(dialog -> cancel(true)) + .create(); + mProgressDialog.show(); + } + + // TODO? suppress for the executeOnExecutor() below + @SuppressLint("WrongThread") + @Override + protected ServerInfoResponse doInBackground(String... params) { + final String country = params.length > 0 ? params[0] : null; // If a country is provided, search within country + + Collection servers; + if(country != null) { + servers = new LinkedList(); + for (PublicServer server : mServers) { + if (country.equals(server.getCountryCode())) { + servers.add(server); + } + } + } else { + servers = mServers; + } + + // For countries with 0 servers, immediately return null. + if(servers.size() == 0) + return null; + + // If there are less servers than the value of our range, deal with it. + mResponsesToSend = Math.min(SEARCH_RANGE, servers.size()); + + Iterator iterator = servers.iterator(); + while(iterator.hasNext() && mGoodResponses.size() < mResponsesToSend) { + PublicServer server = iterator.next(); + new MatchServerInfoTask().executeOnExecutor(mPingExecutor, server); + } + try { + synchronized (mLock) { + mLock.wait(); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + + Collections.sort(mGoodResponses, mLatencyComparator); + + if(mGoodResponses.size() > 0) + return mGoodResponses.get(0); + else + return null; + } + + @Override + protected void onPostExecute(ServerInfoResponse response) { + super.onPostExecute(response); + final PublicServer publicServer = response == null ? null : (PublicServer) response.getServer(); + mProgressDialog.hide(); + if (publicServer != null) { + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.server_match_found) + .setMessage(getString(R.string.server_match_info, + publicServer.getName(), + publicServer.getHost(), + publicServer.getPort(), + response.getCurrentUsers(), + response.getMaximumUsers(), + response.getVersionString(), + publicServer.getCountry(), + response.getLatency())) + .setPositiveButton(R.string.connect, (dialog, which) -> + mConnectHandler.connectToPublicServer(publicServer)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.server_match_not_found) + .setMessage(R.string.server_match_expand_country) + .setPositiveButton(R.string.expand, (dialog, which) -> { + MatchServerTask matchTask = new MatchServerTask(); + matchTask.execute(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/ServerAdapter.java b/app/src/main/java/se/lublin/mumla/servers/ServerAdapter.java new file mode 100644 index 00000000..e12aea67 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/ServerAdapter.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.appcompat.widget.PopupMenu; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; + +/** + * Created by andrew on 05/05/14. + */ +public abstract class ServerAdapter extends ArrayAdapter { + private static final int MAX_ACTIVE_PINGS = 50; + + private ConcurrentHashMap mInfoResponses = new ConcurrentHashMap(); + private ExecutorService mPingExecutor = Executors.newFixedThreadPool(MAX_ACTIVE_PINGS); + private int mViewResource; + + public ServerAdapter(Context context, int viewResource, List servers) { + super(context, 0, servers); + mViewResource = viewResource; + } + + @Override + public long getItemId(int position) { + return getItem(position).getId(); + } + + @Override + public View getView(int position, View v, ViewGroup parent) { + View view = v; + + if(v == null) { + LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + view = inflater.inflate(mViewResource, parent, false); + } + + final E server = getItem(position); + + ServerInfoResponse infoResponse = mInfoResponses.get(server); + // If there is a null value for the server info (rather than none at all), the request must have failed. + boolean requestExists = infoResponse != null; + boolean requestFailure = infoResponse != null && infoResponse.isDummy(); + + TextView nameText = (TextView) view.findViewById(R.id.server_row_name); + TextView userText = (TextView) view.findViewById(R.id.server_row_user); + TextView addressText = (TextView) view.findViewById(R.id.server_row_address); + + nameText.setText(server.getName()); + + if(userText != null) userText.setText(server.getUsername()); + if (addressText != null) { + addressText.setText(server.getHost() + + (server.getPort() == 0 ? "" : ":" + server.getPort())); + } + + final ImageView moreButton = (ImageView) view.findViewById(R.id.server_row_more); + if(moreButton != null) { + moreButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onServerOptionsClick(server, moreButton); + } + }); + } + + TextView serverVersionText = (TextView) view.findViewById(R.id.server_row_version_status); + TextView serverLatencyText = (TextView) view.findViewById(R.id.server_row_latency); + TextView serverUsersText = (TextView) view.findViewById(R.id.server_row_usercount); + ProgressBar serverInfoProgressBar = (ProgressBar) view.findViewById(R.id.server_row_ping_progress); + + serverVersionText.setVisibility(!requestExists ? View.INVISIBLE : View.VISIBLE); + serverUsersText.setVisibility(!requestExists ? View.INVISIBLE : View.VISIBLE); + serverLatencyText.setVisibility(!requestExists ? View.INVISIBLE : View.VISIBLE); + serverInfoProgressBar.setVisibility(!requestExists ? View.VISIBLE : View.INVISIBLE); + + if(infoResponse != null && !requestFailure) { + serverVersionText.setText(getContext().getString(R.string.online)+" ("+infoResponse.getVersionString()+")"); + serverUsersText.setText(infoResponse.getCurrentUsers()+"/"+infoResponse.getMaximumUsers()); + serverLatencyText.setText(infoResponse.getLatency()+"ms"); + } else if(requestFailure) { + serverVersionText.setText(R.string.offline); + serverUsersText.setText(""); + serverLatencyText.setText(""); + } + + // Ping server if available + if(infoResponse == null) { + ServerInfoTask task = new ServerInfoTask() { + protected void onPostExecute(ServerInfoResponse result) { + super.onPostExecute(result); + mInfoResponses.put(server, result); + notifyDataSetChanged(); + } + }; + task.executeOnExecutor(mPingExecutor, server); + } + + return view; + } + + private void onServerOptionsClick(final Server server, View optionsButton) { + PopupMenu popupMenu = new PopupMenu(getContext(), optionsButton); + popupMenu.inflate(getPopupMenuResource()); + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + return onPopupItemClick(server, menuItem); + } + }); + popupMenu.show(); + } + + public abstract int getPopupMenuResource(); + public abstract boolean onPopupItemClick(Server server, MenuItem menuItem); +} diff --git a/app/src/main/java/se/lublin/mumla/servers/ServerEditFragment.java b/app/src/main/java/se/lublin/mumla/servers/ServerEditFragment.java new file mode 100644 index 00000000..7027a9c9 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/ServerEditFragment.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import se.lublin.humla.model.Server; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; + +public class ServerEditFragment extends DialogFragment { + private static final String ARGUMENT_SERVER = "server"; + private static final String ARGUMENT_ACTION = "action"; + private static final String ARGUMENT_IGNORE_TITLE = "ignore_title"; + + private EditText mNameEdit; + private EditText mHostEdit; + private EditText mPortEdit; + private EditText mUsernameEdit; + private EditText mPasswordEdit; + + private ServerEditListener mListener; + + /** + * Creates a new {@link ServerEditFragment} dialog. Results will be delivered to the parent + * activity via {@link ServerEditListener}. + * @param server Optional, if set will populate the fragment with data from the server. + * @param action The action the fragment is performing (i.e. Add, Edit) + * @param ignoreTitle If true, don't show fields related to the server title (useful for quick + * connect dialogs) + */ + public static DialogFragment createServerEditDialog(Context context, Server server, + Action action, + boolean ignoreTitle) { + Bundle args = new Bundle(); + args.putParcelable(ARGUMENT_SERVER, server); + args.putInt(ARGUMENT_ACTION, action.ordinal()); + args.putBoolean(ARGUMENT_IGNORE_TITLE, ignoreTitle); + return (DialogFragment) Fragment.instantiate(context, ServerEditFragment.class.getName(), args); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + try { + mListener = (ServerEditListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement ServerEditListener!"); + } + } + + @Override + public void onStart() { + super.onStart(); + // Override positive button to not automatically dismiss on press. + // We can't accomplish this with AlertDialog.Builder. + ((AlertDialog)getDialog()).getButton(Dialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (validate()) { + Server server = createServer(); + mListener.onServerEdited(getAction(), server); + dismiss(); + } + } + }); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Settings settings = Settings.getInstance(getActivity()); + + String actionName; + switch (getAction()) { + case ADD_ACTION: + actionName = getString(R.string.add); + break; + case EDIT_ACTION: + actionName = getString(android.R.string.ok); + break; + case CONNECT_ACTION: + actionName = getString(R.string.connect); + break; + default: + throw new RuntimeException("Unknown action " + getAction()); + } + + LayoutInflater inflater = LayoutInflater.from(getActivity()); + View view = inflater.inflate(R.layout.dialog_server_edit, null, false); + + TextView titleLabel = view.findViewById(R.id.server_edit_name_title); + mNameEdit = view.findViewById(R.id.server_edit_name); + mHostEdit = view.findViewById(R.id.server_edit_host); + mPortEdit = view.findViewById(R.id.server_edit_port); + mUsernameEdit = view.findViewById(R.id.server_edit_username); + mUsernameEdit.setHint(settings.getDefaultUsername()); + mPasswordEdit = view.findViewById(R.id.server_edit_password); + + Server oldServer = getServer(); + if (oldServer != null) { + mNameEdit.setText(oldServer.getName()); + mHostEdit.setText(oldServer.getHost()); + if (oldServer.getPort() != 0) { + mPortEdit.setText(String.valueOf(oldServer.getPort())); + } + mUsernameEdit.setText(oldServer.getUsername()); + mPasswordEdit.setText(oldServer.getPassword()); + } + + if (shouldIgnoreTitle()) { + titleLabel.setVisibility(View.GONE); + mNameEdit.setVisibility(View.GONE); + } + + return new MaterialAlertDialogBuilder(requireActivity()) + .setPositiveButton(actionName, null) + .setNegativeButton(android.R.string.cancel, null) + .setView(view) + .create(); + } + + public Server createServer() { + String name = (mNameEdit).getText().toString().trim(); + String host = (mHostEdit).getText().toString().trim(); + + int port; + try { + port = Integer.parseInt((mPortEdit).getText().toString()); + } catch (final NumberFormatException ex) { + // Setting 0, meaning that port isn't configured. Consumers of + // Server.getPort() will have to deal with that. Like displaying + // nothing, looking up SRV record, using Constants.DEFAULT_PORT. + port = 0; + } + + String username = (mUsernameEdit).getText().toString().trim(); + String password = mPasswordEdit.getText().toString(); + + if (username.equals("")) + username = mUsernameEdit.getHint().toString(); + + // Inherit database ID of provided server. + long id; + if (getServer() != null) { + id = getServer().getId(); + } else { + id = -1; + } + + return new Server(id, name, host, port, username, password); + } + + /** + * Checks all fields in this ServerEditFragment for validity. + * If an invalid field is found, an error is shown and false is returned. + * @return true if the inputted values are valid, false otherwise. + */ + public boolean validate() { + if (mHostEdit.getText().length() == 0) { + mHostEdit.setError(getString(R.string.invalid_host)); + return false; + } else if (mPortEdit.getText().length() > 0) { + try { + int port = Integer.parseInt(mPortEdit.getText().toString()); + if (port < 1 || port > 65535) { + mPortEdit.setError(getString(R.string.invalid_port_range)); + return false; + } + } catch (NumberFormatException nfe) { + mPortEdit.setError(getString(R.string.invalid_port_range)); + return false; + } + } + return true; + } + + private Server getServer() { + return getArguments().getParcelable(ARGUMENT_SERVER); + } + + private Action getAction() { + return Action.values()[getArguments().getInt(ARGUMENT_ACTION)]; + } + + private boolean shouldIgnoreTitle() { + return getArguments().getBoolean(ARGUMENT_IGNORE_TITLE); + } + + public interface ServerEditListener { + void onServerEdited(Action action, Server server); + } + + public enum Action { + CONNECT_ACTION, + EDIT_ACTION, + ADD_ACTION + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/ServerInfoResponse.java b/app/src/main/java/se/lublin/mumla/servers/ServerInfoResponse.java new file mode 100644 index 00000000..218589b3 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/ServerInfoResponse.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import java.nio.ByteBuffer; + +import se.lublin.humla.model.Server; + +/** + * Response from server pings. + * @see http://mumble.sourceforge.net/Protocol + * @author morlunk + */ +public class ServerInfoResponse { + + private long mIdentifier; + private int mVersion; + private int mCurrentUsers; + private int mMaximumUsers; + private int mAllowedBandwidth; + private int mLatency; + private Server mServer; + + /** + * Whether or not this server info response represents a failure to retrieve a response. Used to efficiently denote failed responses. + */ + private boolean mDummy = false; + + /** + * Creates a ServerInfoResponse object with the bytes obtained from the server. + * @param response The response to the UDP pings sent by the server. + * @see http://mumble.sourceforge.net/Protocol + */ + public ServerInfoResponse(Server server, byte[] response, int latency) { + ByteBuffer buffer = ByteBuffer.wrap(response); + mVersion = buffer.getInt(); + mIdentifier = buffer.getLong(); + mCurrentUsers = buffer.getInt(); + mMaximumUsers = buffer.getInt(); + mAllowedBandwidth = buffer.getInt(); + mLatency = latency; + mServer = server; + } + + /** + * Instantiating a ServerInfoResponse with no data will cause it to be considered a 'dummy' response by its handler. + */ + public ServerInfoResponse() { + this.mDummy = true; + } + + public long getIdentifier() { + return mIdentifier; + } + + public int getVersion() { + return mVersion; + } + + public String getVersionString() { + byte[] versionBytes = ByteBuffer.allocate(4).putInt(mVersion).array(); + return String.format("%d.%d.%d", (int)versionBytes[1], (int)versionBytes[2], (int)versionBytes[3]); + } + + public int getCurrentUsers() { + return mCurrentUsers; + } + + public int getMaximumUsers() { + return mMaximumUsers; + } + + public int getAllowedBandwidth() { + return mAllowedBandwidth; + } + + public int getLatency() { + return mLatency; + } + + public Server getServer() { + return mServer; + } + + public boolean isDummy() { + return mDummy; + } +} diff --git a/app/src/main/java/se/lublin/mumla/servers/ServerInfoTask.java b/app/src/main/java/se/lublin/mumla/servers/ServerInfoTask.java new file mode 100644 index 00000000..9ea0c762 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/servers/ServerInfoTask.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.servers; + +import android.os.AsyncTask; +import android.util.Log; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.ByteBuffer; + +import se.lublin.humla.model.Server; + +/** + * Pings the requested server and returns a ServerInfoResponse. + * Will return a 'dummy' ServerInfoResponse in the case of failure. + * @author morlunk + * + */ +public class ServerInfoTask extends AsyncTask { + private static final String TAG = ServerInfoTask.class.getName(); + + private Server server; + + @Override + protected ServerInfoResponse doInBackground(Server... params) { + server = params[0]; + try { + // Create ping message + ByteBuffer buffer = ByteBuffer.allocate(12); + buffer.putInt(0); // Request type + buffer.putLong(server.getId()); // Identifier + DatagramPacket requestPacket = new DatagramPacket(buffer.array(), 12, + InetAddress.getByName(server.getSrvHost()), server.getSrvPort()); + + // Send packet and wait for response + DatagramSocket socket = new DatagramSocket(); + socket.setSoTimeout(1000); + socket.setReceiveBufferSize(1024); + + long startTime = System.nanoTime(); + + socket.send(requestPacket); + + byte[] responseBuffer = new byte[24]; + DatagramPacket responsePacket = new DatagramPacket(responseBuffer, responseBuffer.length); + socket.receive(responsePacket); + + int latencyInMs = (int) ((System.nanoTime()-startTime)/1000000); + + ServerInfoResponse response = new ServerInfoResponse(server, responseBuffer, latencyInMs); + + Log.d(TAG, "Server version: " + response.getVersionString() + + " Users: " + response.getCurrentUsers() + "/" + response.getMaximumUsers()); + + return response; + + } catch (Exception e) { +// e.printStackTrace(); + } + + return new ServerInfoResponse(); // Return dummy in case of failure + } + +} diff --git a/app/src/main/java/se/lublin/mumla/service/IChatMessage.java b/app/src/main/java/se/lublin/mumla/service/IChatMessage.java new file mode 100644 index 00000000..39d62c17 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/IChatMessage.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2015 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import java.util.Date; + +import se.lublin.humla.model.IMessage; + +/** + * A general chat message, either a text message from a user or an + * informational notice. + * Created by andrew on 28/04/15. + */ +public interface IChatMessage { + /** + * @return The body of the message. + */ + String getBody(); + + /** + * @return the unix timestamp when the message was received. + */ + long getReceivedTime(); + + /** + * Calls the provided visitor object with the proper message implementation. + * @param visitor A visitor object responding to the underlying chat message type. + */ + void accept(Visitor visitor); + + /** + * A text message from a user. + */ + class TextMessage implements IChatMessage { + private final IMessage mMessage; + + public TextMessage(IMessage message) { + mMessage = message; + } + + public IMessage getMessage() { + return mMessage; + } + + @Override + public String getBody() { + return mMessage.getMessage(); + } + + @Override + public long getReceivedTime() { + return mMessage.getReceivedTime(); + } + + @Override + public void accept(Visitor visitor) { + visitor.visit(this); + } + } + + /** + * An informational message about the server or client state. + */ + class InfoMessage implements IChatMessage { + /** + * The type of informational message. + */ + private final Type mType; + /** + * The message body. + */ + private final String mBody; + /** + * The time the message was received, as a unix timestamp. + */ + private final long mReceivedTime; + + public InfoMessage(Type type, String message) { + mType = type; + mBody = message; + mReceivedTime = new Date().getTime(); + } + + public Type getType() { + return mType; + } + + @Override + public String getBody() { + return mBody; + } + + @Override + public long getReceivedTime() { + return mReceivedTime; + } + + @Override + public void accept(Visitor visitor) { + visitor.visit(this); + } + + public enum Type { + INFO, + WARNING, + ERROR + } + } + + interface Visitor { + void visit(TextMessage message); + void visit(InfoMessage message); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/IMumlaService.java b/app/src/main/java/se/lublin/mumla/service/IMumlaService.java new file mode 100644 index 00000000..acd31fad --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/IMumlaService.java @@ -0,0 +1,42 @@ +package se.lublin.mumla.service; + +import java.util.List; + +import se.lublin.humla.IHumlaService; + +/** + * Created by andrew on 28/02/17. + */ +public interface IMumlaService extends IHumlaService { + void setOverlayShown(boolean showOverlay); + + boolean isOverlayShown(); + + void clearChatNotifications(); + + void markErrorShown(); + + boolean isErrorShown(); + + void onTalkKeyDown(); + + void onTalkKeyUp(); + + /** Blocks PTT until a subsequent key-up proves the recovery press has ended. */ + void requirePttRelease(); + + /** Updates the managed-radio TX gate after the configured room has been verified. */ + void setRadioRoomReady(boolean ready); + + boolean isRadioReceiving(); + + List getRadioTalkers(); + + List getMessageLog(); + + void clearMessageLog(); + + void reloadTrackingConfig(); + + void setSuppressNotifications(boolean suppressNotifications); +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java new file mode 100644 index 00000000..037f80b4 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaConnectionNotification.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; +import static android.app.PendingIntent.FLAG_IMMUTABLE; +import static android.content.Context.RECEIVER_NOT_EXPORTED; +import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; + +import androidx.core.app.NotificationCompat; + +import se.lublin.mumla.R; +import se.lublin.mumla.app.DrawerAdapter; +import se.lublin.mumla.app.MumlaActivity; + +/** + * Wrapper to create Mumla notifications. + * Created by andrew on 08/08/14. + */ +public class MumlaConnectionNotification { + private static final int NOTIFICATION_ID = 1; + private static final String BROADCAST_MUTE = "b_mute"; + private static final String BROADCAST_DEAFEN = "b_deafen"; + private static final String BROADCAST_OVERLAY = "b_overlay"; + + private Service mService; + private OnActionListener mListener; + private String mCustomContentText; + private boolean mActionsShown; + + private BroadcastReceiver mNotificationReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BROADCAST_MUTE.equals(intent.getAction())) { + mListener.onMuteToggled(); + } else if (BROADCAST_DEAFEN.equals(intent.getAction())) { + mListener.onDeafenToggled(); + } else if (BROADCAST_OVERLAY.equals(intent.getAction())) { + mListener.onOverlayToggled(); + } + } + }; + + /** + * Creates a foreground Mumla notification for the given service. + * @param service The service to register a foreground notification for. + * @param listener An listener for notification actions. + * @return A new MumlaNotification instance. + */ + public static MumlaConnectionNotification create(Service service, String contentText, + OnActionListener listener) { + return new MumlaConnectionNotification(service, contentText, listener); + } + + private MumlaConnectionNotification(Service service, String contentText, + OnActionListener listener) { + mService = service; + mListener = listener; + mCustomContentText = contentText; + mActionsShown = false; + } + + public void setCustomContentText(String text) { + mCustomContentText = text; + } + + public void setActionsShown(boolean actionsShown) { + mActionsShown = actionsShown; + } + + /** + * Shows the notification and registers the notification action button receiver. + */ + public void show() { + createNotification(); + + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_DEAFEN); + filter.addAction(BROADCAST_MUTE); + filter.addAction(BROADCAST_OVERLAY); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mService.registerReceiver(mNotificationReceiver, filter, RECEIVER_NOT_EXPORTED); + } else { + mService.registerReceiver(mNotificationReceiver, filter); + } + } catch (IllegalArgumentException e) { + // Thrown if receiver is already registered. + e.printStackTrace(); + } + } + + /** + * Hides the notification and unregisters the action receiver. + */ + public void hide() { + try { + mService.unregisterReceiver(mNotificationReceiver); + } catch (IllegalArgumentException e) { + // Thrown if receiver is not registered. + e.printStackTrace(); + } + mService.stopForeground(true); + } + + /** + * Called to update/create the service's foreground Mumla notification. + */ + private Notification createNotification() { + String channelId = ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + channelId = "connected_channel"; + String channelName = mService.getString(R.string.connected); + NotificationChannel chan = new NotificationChannel(channelId, channelName, + NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager manager = mService.getSystemService(NotificationManager.class); + manager.createNotificationChannel(chan); + } + NotificationCompat.Builder builder = + new NotificationCompat.Builder(mService, channelId); + + // app name is always displayed in notification on >= O + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + builder.setContentTitle(mService.getString(R.string.app_name)); + } + builder.setContentText(mCustomContentText); + builder.setSmallIcon(R.drawable.ic_stat_notify); + builder.setPriority(NotificationCompat.PRIORITY_DEFAULT); + builder.setCategory(NotificationCompat.CATEGORY_CALL); + builder.setShowWhen(false); + builder.setOngoing(true); + + if (mActionsShown) { + // Add notification triggers + Intent muteIntent = new Intent(BROADCAST_MUTE); + muteIntent.setPackage(mService.getPackageName()); + Intent deafenIntent = new Intent(BROADCAST_DEAFEN); + deafenIntent.setPackage(mService.getPackageName()); + Intent overlayIntent = new Intent(BROADCAST_OVERLAY); + overlayIntent.setPackage(mService.getPackageName()); + + builder.addAction(R.drawable.ic_action_microphone, + mService.getString(R.string.mute), PendingIntent.getBroadcast(mService, 1, + muteIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + builder.addAction(R.drawable.ic_action_audio, + mService.getString(R.string.deafen), PendingIntent.getBroadcast(mService, 1, + deafenIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + builder.addAction(R.drawable.ic_action_channels, + mService.getString(R.string.overlay), PendingIntent.getBroadcast(mService, 2, + overlayIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + } + + Intent channelListIntent = new Intent(mService, MumlaActivity.class); + channelListIntent.putExtra(MumlaActivity.EXTRA_DRAWER_FRAGMENT, DrawerAdapter.ITEM_SERVER); + // FLAG_CANCEL_CURRENT ensures that the extra always gets sent. + PendingIntent pendingIntent = PendingIntent.getActivity(mService, 0, + channelListIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); + builder.setContentIntent(pendingIntent); + + Notification notification = builder.build(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mService.startForeground(NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else { + mService.startForeground(NOTIFICATION_ID, notification); + } + return notification; + } + + public interface OnActionListener { + void onMuteToggled(); + void onDeafenToggled(); + void onOverlayToggled(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaHotCorner.java b/app/src/main/java/se/lublin/mumla/service/MumlaHotCorner.java new file mode 100644 index 00000000..8c12b9fa --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaHotCorner.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import android.content.Context; +import android.content.Intent; +import android.graphics.PixelFormat; +import android.net.Uri; +import android.os.Build; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.Toast; + +import androidx.core.content.ContextCompat; + +import se.lublin.mumla.R; + +/** + * A hot corner in an area of the screen specified by {@link MumlaHotCorner#getGravity()}. + * Created by andrew on 07/06/14. + */ +public class MumlaHotCorner implements View.OnTouchListener { + private WindowManager mWindowManager; + private Context mContext; + private View mView; + private boolean mShown; + private int mHighlightColour; + private MumlaHotCornerListener mListener; + private WindowManager.LayoutParams mParams; + + public MumlaHotCorner(Context context, int gravity, MumlaHotCornerListener listener) { + if(listener == null) { + throw new NullPointerException("A MumlaHotCornerListener must be assigned."); + } + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + mContext = context; + mView = inflater.inflate(R.layout.ptt_corner, null, false); + mView.setOnTouchListener(this); + mListener = listener; + mParams = new WindowManager.LayoutParams(WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, + PixelFormat.TRANSLUCENT); + mParams.gravity = gravity; + mHighlightColour = ContextCompat.getColor(mContext, R.color.hot_corner_highlight); + } + + /** + * Updates the hot corner with any new settings applied, recalculating the layout parameters. + * Does nothing if the hot corner is not shown. + */ + private void updateLayout() { + if(!isShown()) return; + mWindowManager.updateViewLayout(mView, mParams); + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mView.setBackgroundColor(mHighlightColour); + mListener.onHotCornerDown(); + return true; + case MotionEvent.ACTION_UP: + mView.setBackgroundColor(0); + mListener.onHotCornerUp(); + return true; + default: + return false; + } + } + + public void setShown(boolean shown) { + if (shown == mShown) { + return; + } + if (shown) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (!android.provider.Settings.canDrawOverlays(mContext)) { + Intent showSetting = new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + mContext.getPackageName())); + showSetting.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(showSetting); + Toast.makeText(mContext, R.string.grant_perm_draw_over_apps, Toast.LENGTH_LONG).show(); + return; + } + } + mWindowManager.addView(mView, mParams); + } else { + mWindowManager.removeView(mView); + } + mShown = shown; + } + + public boolean isShown() { + return mShown; + } + + public void setGravity(int gravity) { + mParams.gravity = gravity; + updateLayout(); + } + + public int getGravity() { + return mParams.gravity; + } + + public static interface MumlaHotCornerListener { + public void onHotCornerDown(); + public void onHotCornerUp(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java new file mode 100644 index 00000000..c170fc10 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaMessageNotification.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2016 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; +import static android.app.PendingIntent.FLAG_IMMUTABLE; + +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.os.Build; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import java.util.ArrayList; +import java.util.List; + +import se.lublin.humla.model.IMessage; +import se.lublin.mumla.R; +import se.lublin.mumla.app.DrawerAdapter; +import se.lublin.mumla.app.MumlaActivity; + +/** + * A notification indicating that new messages have been received. + * Intended to augment the existing {@link MumlaConnectionNotification} by providing a higher + * priority heads-up display on Android 5.0+ devices, as well as vibration. + * Created by andrew on 25/03/16. + */ +public class MumlaMessageNotification { + private static final int NOTIFICATION_ID = 2; + private static final long VIBRATION_PATTERN[] = { 0, 100 }; + + private final Context mContext; + private final List mUnreadMessages; + + public MumlaMessageNotification(Context context) { + mContext = context; + mUnreadMessages = new ArrayList<>(); + } + + /** + * Shows the notification with the provided message. + * If the notification is already shown, append the message to the existing notification. + * @param message The message to notify the user about. + */ + public void show(IMessage message) { + mUnreadMessages.add(message); + + NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle(); + style.setBigContentTitle(mContext.getResources().getQuantityString(R.plurals.notification_unread_many, mUnreadMessages.size(), mUnreadMessages.size())); + for (IMessage m : mUnreadMessages) { + String line = mContext.getString(R.string.notification_message, m.getActorName(), m.getMessage()); + style.addLine(line); + } + + Intent channelListIntent = new Intent(mContext, MumlaActivity.class); + channelListIntent.putExtra(MumlaActivity.EXTRA_DRAWER_FRAGMENT, DrawerAdapter.ITEM_SERVER); + // FLAG_CANCEL_CURRENT ensures that the extra always gets sent. + PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, + channelListIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); + + String channelId = ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + channelId = "message_channel"; + String channelName = mContext.getString(R.string.messageReceived); + NotificationChannel chan = new NotificationChannel(channelId, channelName, + NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager manager = mContext.getSystemService(NotificationManager.class); + manager.createNotificationChannel(chan); + } + NotificationCompat.Builder builder = + new NotificationCompat.Builder(mContext, channelId); + + builder.setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.ic_stat_notify) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setContentTitle(message.getActorName()) + .setContentText(message.getMessage()) + .setVibrate(VIBRATION_PATTERN) + .setStyle(style); + + if (mUnreadMessages.size() > 0) + builder.setNumber(mUnreadMessages.size()); + + final NotificationManagerCompat manager = NotificationManagerCompat.from(mContext); + Notification notification = builder.build(); + manager.notify(NOTIFICATION_ID, notification); + } + + /** + * Dismisses the unread messages notification, marking all messages read. + */ + public void dismiss() { + mUnreadMessages.clear(); + final NotificationManagerCompat manager = NotificationManagerCompat.from(mContext); + manager.cancel(NOTIFICATION_ID); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaOverlay.java b/app/src/main/java/se/lublin/mumla/service/MumlaOverlay.java new file mode 100644 index 00000000..970ea98a --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaOverlay.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.ImageView; +import android.widget.ListView; + +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.channel.ChannelAdapter; + +/** + * An onscreen interactive overlay displaying the users in the current channel. + * Created by andrew on 26/09/13. + */ +public class MumlaOverlay { + private static final String TAG = MumlaOverlay.class.getName(); + + public static final int DEFAULT_WIDTH = 200; + public static final int DEFAULT_HEIGHT = 240; + + private HumlaObserver mObserver = new HumlaObserver() { + @Override + public void onUserTalkStateUpdated(IUser user) { + mChannelAdapter.notifyDataSetChanged(); + } + + @Override + public void onUserStateUpdated(IUser user) { + if(user.getChannel() != null && + user.getChannel().equals(mService.getSessionChannel())) + mChannelAdapter.notifyDataSetChanged(); + } + + @Override + public void onUserJoinedChannel(IUser user, IChannel newChannel, IChannel oldChannel) { + int selfSession; + try { + selfSession = mService.getSessionId(); + } catch (IllegalStateException e) { + Log.d(TAG, "exception in onUserJoinedChannel: " + e); + return; + } + + if (user.getSession() == selfSession) { + // Session user has changed channels + mChannelAdapter.setChannel(mService.getSessionChannel()); + } else if (newChannel.getId() == mService.getSessionChannel().getId() || + oldChannel.getId() == mService.getSessionChannel().getId()) { + mChannelAdapter.notifyDataSetChanged(); + } + } + }; + + private View mOverlayView; + private ListView mOverlayList; + private ChannelAdapter mChannelAdapter; + private ImageView mTalkButton; +// private ImageView mToggleButton; + private ImageView mCloseButton; + private ImageView mDragButton; + private View mTitleView; + private WindowManager.LayoutParams mOverlayParams; + private boolean mShown = false; +// private boolean mShowChat = false; + + private MumlaService mService; + + public MumlaOverlay(MumlaService service) { + mService = service; + mOverlayView = View.inflate(service, R.layout.overlay, null); + mTalkButton = (ImageView) mOverlayView.findViewById(R.id.overlay_talk); + mDragButton = (ImageView) mOverlayView.findViewById(R.id.overlay_drag); + mCloseButton = (ImageView) mOverlayView.findViewById(R.id.overlay_close); +// mToggleButton = (ImageView) mOverlayView.findViewById(R.id.overlay_mode_toggle); + mTitleView = mOverlayView.findViewById(R.id.overlay_title); + mOverlayList = (ListView) mOverlayView.findViewById(R.id.overlay_list); + + mTitleView.setOnTouchListener(new View.OnTouchListener() { + private final WindowManager mWindowManager = (WindowManager) mService.getSystemService(Context.WINDOW_SERVICE); + private float mInitialX; + private float mInitialY; + + @Override + public boolean onTouch(View v, MotionEvent event) { + if(MotionEvent.ACTION_DOWN == event.getAction()) { + mInitialX = event.getRawX() - mOverlayParams.x; + mInitialY = event.getRawY() - mOverlayParams.y; + return true; + } else if(MotionEvent.ACTION_MOVE == event.getAction()) { + mOverlayParams.x = (int) (event.getRawX() - mInitialX); + mOverlayParams.y = (int) (event.getRawY() - mInitialY); + mWindowManager.updateViewLayout(mOverlayView, mOverlayParams); + return true; + } + return false; + } + }); + + mDragButton.setOnTouchListener(new View.OnTouchListener() { + + private final WindowManager mWindowManager = (WindowManager) mService.getSystemService(Context.WINDOW_SERVICE); + private float mInitialX; + private float mInitialY; + private float mInitialWidth; + private float mInitialHeight; + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mInitialX = event.getRawX(); + mInitialY = event.getRawY(); + mInitialWidth = mOverlayView.getWidth(); + mInitialHeight = mOverlayView.getHeight(); + return true; + case MotionEvent.ACTION_MOVE: + mOverlayParams.width = (int) (mInitialWidth + (event.getRawX() - mInitialX)); + mOverlayParams.height = (int) (mInitialHeight + (event.getRawY() - mInitialY)); + mWindowManager.updateViewLayout(mOverlayView, mOverlayParams); + return true; + } + return false; + } + }); + + /* + mToggleButton.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + mShowChat = !mShowChat; + // TODO implement chat + if(mShowChat) { + + } else { + + } + } + }); + */ + + mTalkButton.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if(MotionEvent.ACTION_DOWN == event.getAction()) { + mService.setTalkingState(true); + return true; + } else if(MotionEvent.ACTION_UP == event.getAction()) { + mService.setTalkingState(false); + return true; + } + return false; + } + }); + + Settings settings = Settings.getInstance(service); + boolean usingPtt = Settings.ARRAY_INPUT_METHOD_PTT.equals(settings.getInputMethod()); + setPushToTalkShown(usingPtt); + + mCloseButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + hide(); + } + }); + + DisplayMetrics metrics = mService.getResources().getDisplayMetrics(); + mOverlayParams = new WindowManager.LayoutParams((int)(DEFAULT_WIDTH*metrics.density), + (int)(DEFAULT_HEIGHT*metrics.density), + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT); + mOverlayParams.gravity = Gravity.TOP | Gravity.LEFT; + mOverlayParams.windowAnimations = android.R.style.Animation_Dialog; + } + + public boolean isShown() { + return mShown; + } + + public void show() { + if(mShown) + return; + mShown = true; + mChannelAdapter = new ChannelAdapter(mService, mService.getSessionChannel()); + mOverlayList.setAdapter(mChannelAdapter); + mService.registerObserver(mObserver); + WindowManager windowManager = (WindowManager) mService.getSystemService(Context.WINDOW_SERVICE); + windowManager.addView(mOverlayView, mOverlayParams); + } + + public void hide() { + if(!mShown) + return; + mShown = false; + mService.unregisterObserver(mObserver); + mOverlayList.setAdapter(null); + try { + WindowManager windowManager = (WindowManager) mService.getSystemService(Context.WINDOW_SERVICE); + windowManager.removeView(mOverlayView); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + } + + public void setPushToTalkShown(boolean showPtt) { + mTalkButton.setVisibility(showPtt ? View.VISIBLE : View.GONE); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java b/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java new file mode 100644 index 00000000..2a85e07c --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaReconnectNotification.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2015 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; +import static android.app.PendingIntent.FLAG_IMMUTABLE; +import static android.content.Context.RECEIVER_NOT_EXPORTED; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import se.lublin.mumla.R; + +/** + * A notification indicating auto-reconnect is in progress, or if auto-reconnect is disabled, + * a prompt to reconnect with the error message. + * Created by andrew on 17/01/15. + */ +public class MumlaReconnectNotification { + private static final int NOTIFICATION_ID = 2; + private static final String BROADCAST_DISMISS = "b_dismiss"; + private static final String BROADCAST_RECONNECT = "b_reconnect"; + private static final String BROADCAST_CANCEL_RECONNECT = "b_cancel_reconnect"; + + private Context mContext; + private OnActionListener mListener; + + private BroadcastReceiver mNotificationReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BROADCAST_DISMISS.equals(intent.getAction())) { + mListener.onReconnectNotificationDismissed(); + } else if (BROADCAST_RECONNECT.equals(intent.getAction())) { + mListener.reconnect(); + } else if (BROADCAST_CANCEL_RECONNECT.equals(intent.getAction())) { + mListener.cancelReconnect(); + } + } + }; + + public static MumlaReconnectNotification show(Context context, + String error, + boolean autoReconnect, + OnActionListener listener) { + MumlaReconnectNotification notification = new MumlaReconnectNotification(context, listener); + notification.show(error, autoReconnect); + return notification; + } + + public MumlaReconnectNotification(Context context, OnActionListener listener) { + mContext = context; + mListener = listener; + } + + public void show(String error, boolean autoReconnect) { + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_DISMISS); + filter.addAction(BROADCAST_RECONNECT); + filter.addAction(BROADCAST_CANCEL_RECONNECT); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mContext.registerReceiver(mNotificationReceiver, filter, RECEIVER_NOT_EXPORTED); + } else { + mContext.registerReceiver(mNotificationReceiver, filter); + } + } catch (IllegalArgumentException e) { + // Thrown if receiver is already registered. + e.printStackTrace(); + } + + String channelId = ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + channelId = "reconnecting_channel"; + // TODO this is not used + String channelName = "Reconnecting"; + NotificationChannel chan = new NotificationChannel(channelId, channelName, + NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager manager = mContext.getSystemService(NotificationManager.class); + manager.createNotificationChannel(chan); + } + NotificationCompat.Builder builder = + new NotificationCompat.Builder(mContext, channelId); + + builder.setSmallIcon(R.drawable.ic_stat_notify); + builder.setPriority(NotificationCompat.PRIORITY_MAX); + builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE | NotificationCompat.DEFAULT_LIGHTS); + builder.setContentTitle(mContext.getString(R.string.mumlaDisconnected)); + builder.setContentText(error); + + Intent dismissIntent = new Intent(BROADCAST_DISMISS); + dismissIntent.setPackage(mContext.getPackageName()); + builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 2, + dismissIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + + if (autoReconnect) { + Intent cancelIntent = new Intent(BROADCAST_CANCEL_RECONNECT); + cancelIntent.setPackage(mContext.getPackageName()); + builder.addAction(R.drawable.ic_action_delete_dark, + mContext.getString(R.string.cancel_reconnect), PendingIntent.getBroadcast(mContext, 2, + cancelIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + builder.setOngoing(true); + } else { + Intent reconnectIntent = new Intent(BROADCAST_RECONNECT); + reconnectIntent.setPackage(mContext.getPackageName()); + builder.addAction(R.drawable.ic_action_move, + mContext.getString(R.string.reconnect), PendingIntent.getBroadcast(mContext, 2, + reconnectIntent, FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE)); + } + + NotificationManagerCompat nmc = NotificationManagerCompat.from(mContext); + nmc.notify(NOTIFICATION_ID, builder.build()); + } + + public void hide() { + try { + mContext.unregisterReceiver(mNotificationReceiver); + } catch (IllegalArgumentException e) { + // Thrown if receiver is not registered. + e.printStackTrace(); + } + NotificationManagerCompat nmc = NotificationManagerCompat.from(mContext); + nmc.cancel(NOTIFICATION_ID); + } + + public interface OnActionListener { + public void onReconnectNotificationDismissed(); + public void reconnect(); + public void cancelReconnect(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/MumlaService.java b/app/src/main/java/se/lublin/mumla/service/MumlaService.java new file mode 100644 index 00000000..4e173434 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/MumlaService.java @@ -0,0 +1,1133 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.service; + +import android.content.BroadcastReceiver; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.media.session.MediaSession; +import android.media.ToneGenerator; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.os.SystemClock; +import android.speech.tts.TextToSpeech; +import android.util.Log; +import android.view.KeyEvent; +import android.widget.Toast; + +import androidx.preference.PreferenceManager; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import se.lublin.humla.Constants; +import se.lublin.humla.HumlaService; +import se.lublin.humla.exception.AudioException; +import se.lublin.humla.model.IMessage; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.Message; +import se.lublin.humla.model.TalkState; +import se.lublin.humla.util.HumlaException; +import se.lublin.humla.util.HumlaObserver; +import se.lublin.mumla.R; +import se.lublin.mumla.Settings; +import se.lublin.mumla.service.ipc.TalkBroadcastReceiver; +import se.lublin.mumla.util.HtmlUtils; +import se.lublin.mumla.radio.RadioPttKeyManager; +import se.lublin.mumla.radio.RadioPttRecoveryGuard; +import se.lublin.mumla.radio.RadioPttSafetyPolicy; +import se.lublin.mumla.radio.RadioReceiveTracker; +import se.lublin.mumla.radio.RadioDeviceProfile; +import se.lublin.mumla.radio.RadioKeyDiagnostics; +import se.lublin.mumla.radio.RadioNotificationPolicy; +import se.lublin.mumla.radio.RadioProcessWatchdog; +import se.lublin.mumla.radio.RadioShellActivity; +import se.lublin.mumla.radio.RadioConfigRepository; +import se.lublin.mumla.radio.tracking.AprsTrackingManager; + +/** + * An extension of the Humla service with some added Mumla-exclusive non-standard Mumble features. + * Created by andrew on 28/07/13. + */ +public class MumlaService extends HumlaService implements + SharedPreferences.OnSharedPreferenceChangeListener, + MumlaConnectionNotification.OnActionListener, + MumlaReconnectNotification.OnActionListener, IMumlaService { + private static final String TAG = MumlaService.class.getName(); + public static final String ACTION_RADIO_PTT_FAILURE = + "se.lublin.mumla.action.RADIO_PTT_FAILURE"; + public static final String ACTION_RADIO_REQUIRE_PTT_RELEASE = + "se.lublin.mumla.action.RADIO_REQUIRE_PTT_RELEASE"; + public static final String ACTION_RADIO_PTT_RELEASED = + "se.lublin.mumla.action.RADIO_PTT_RELEASED"; + public static final String ACTION_RADIO_PTT_DOWN = + "se.lublin.mumla.action.RADIO_PTT_DOWN"; + public static final String ACTION_RADIO_PTT_UP = + "se.lublin.mumla.action.RADIO_PTT_UP"; + public static final String ACTION_RADIO_TRACKING_POLL = + "se.lublin.mumla.action.RADIO_TRACKING_POLL"; + + /** Undocumented constant that permits a proximity-sensing wake lock. */ + public static final int PROXIMITY_SCREEN_OFF_WAKE_LOCK = 32; + public static final int TTS_THRESHOLD = 250; // Maximum number of characters to read + public static final int RECONNECT_DELAY = 10000; + static final int MAX_MESSAGE_LOG_ENTRIES = 256; + private static final int MAX_PTT_SECONDS = 120; + private static final long PTT_DELIVERY_CONFIRM_MS = 1500L; + private static final long RADIO_WAKE_COOLDOWN_MS = 1000L; + private static final long RADIO_WAKE_DURATION_MS = 5000L; + private static final Locale THAI_LOCALE = new Locale("th", "TH"); + private static volatile MumlaService sRunningService; + + private Settings mSettings; + private MumlaConnectionNotification mNotification; + private MumlaMessageNotification mMessageNotification; + private MumlaReconnectNotification mReconnectNotification; + /** Channel view overlay. */ + private MumlaOverlay mChannelOverlay; + /** Proximity lock for handset mode. */ + private PowerManager.WakeLock mProximityLock; + /** Play sound when push to talk key is pressed */ + private boolean mPTTSoundEnabled; + /** + * Media-session bridge for hardware/media PTT keys while the Activity is not focused. + * + * T99 exposes a Button Jack with KEY_MEDIA. A MediaSession is the app-level Android API + * that can receive media-button events while the screen is off. F1/F2 and raw GPIO keys + * still require an OEM broadcast or privileged input path. + */ + private MediaSession mPttMediaSession; + private boolean mRadioRoomReady; + private boolean mPttMediaKeyDown; + private final Handler mPttWatchdogHandler = new Handler(Looper.getMainLooper()); + private boolean mPttInputDown; + private boolean mPttWatchdogLockout; + private final RadioReceiveTracker mRadioReceiveTracker = new RadioReceiveTracker(); + private final Runnable mRadioProcessWatchdogHeartbeat = new Runnable() { + @Override + public void run() { + if (!isManagedRadioDevice()) { + return; + } + RadioProcessWatchdog.arm(MumlaService.this); + mPttWatchdogHandler.postDelayed(this, + RadioProcessWatchdog.HEARTBEAT_INTERVAL_MS); + } + }; + private long mPttPressStartedElapsedRealtime; + private boolean mPttFailureAlerted; + private long mLastRadioWakeElapsedRealtime; + private ToneGenerator mRadioAlertTone; + private PowerManager.WakeLock mRadioScreenWakeLock; + private final Runnable mPttDeliveryFailureCheck = new Runnable() { + @Override + public void run() { + if (mPttInputDown && mPttPressStartedElapsedRealtime > 0L + && getLastAudioPacketSentElapsedRealtime() + < mPttPressStartedElapsedRealtime) { + mPttFailureAlerted = true; + playPttFailureAlert(); + } + } + }; + private final Runnable mPttWatchdog = new Runnable() { + @Override + public void run() { + if (!mPttInputDown) { + return; + } + + // Fail safe: stop transmitting and require a real release before another TX. + mPttWatchdogLockout = true; + mPttInputDown = false; + if (isTalking()) { + setTalkingState(false); + } + playPttFailureAlert(); + Log.w(TAG, "PTT watchdog stopped transmission after " + MAX_PTT_SECONDS + " seconds"); + } + }; + /** Try to shorten spoken messages when using TTS */ + private boolean mShortTtsMessagesEnabled; + /** + * True if an error causing disconnection has been dismissed by the user. + * This should serve as a hint not to bother the user. + */ + private boolean mErrorShown; + private List mMessageLog; + private boolean mSuppressNotifications; + private AprsTrackingManager mAprsTrackingManager; + + private TextToSpeech mTTS; + private TextToSpeech.OnInitListener mTTSInitListener = new TextToSpeech.OnInitListener() { + @Override + public void onInit(int status) { + if(status == TextToSpeech.ERROR) { + logWarning(getString(R.string.tts_failed)); + return; + } + if (mTTS != null) { + int thai = mTTS.isLanguageAvailable(THAI_LOCALE); + if (thai >= TextToSpeech.LANG_AVAILABLE) { + mTTS.setLanguage(THAI_LOCALE); + } else { + Log.w(TAG, "Thai TTS voice is not installed; retaining engine default"); + } + } + } + }; + + /** The view representing the hot corner. */ + private MumlaHotCorner mHotCorner; + private MumlaHotCorner.MumlaHotCornerListener mHotCornerListener = new MumlaHotCorner.MumlaHotCornerListener() { + @Override + public void onHotCornerDown() { + onTalkKeyDown(); + } + + @Override + public void onHotCornerUp() { + onTalkKeyUp(); + } + }; + + private BroadcastReceiver mTalkReceiver; + + private HumlaObserver mObserver = new HumlaObserver() { + @Override + public void onConnecting() { + // Remove old notification left from reconnect, + if (mReconnectNotification != null) { + mReconnectNotification.hide(); + mReconnectNotification = null; + } + + final String tor = mSettings.isTorEnabled() ? " (Tor)" : ""; + mNotification = MumlaConnectionNotification.create(MumlaService.this, + getString(R.string.mumlaConnecting) + tor, + MumlaService.this); + mNotification.show(); + + mErrorShown = false; + } + + @Override + public void onConnected() { + if (mNotification != null) { + final String tor = mSettings.isTorEnabled() ? " (Tor)" : ""; + mNotification.setCustomContentText(getString(R.string.connected) + tor); + mNotification.setActionsShown(true); + mNotification.show(); + } + } + + @Override + public void onDisconnected(HumlaException e) { + mRadioReceiveTracker.clear(); + wakeRadioDisplay(); + if (mNotification != null) { + mNotification.hide(); + mNotification = null; + } + if (e != null && !mSuppressNotifications) { + mReconnectNotification = + MumlaReconnectNotification.show(MumlaService.this, + e.getMessage() + (mSettings.isTorEnabled() ? " (Tor)" : ""), + isReconnecting(), MumlaService.this); + } + } + + @Override + public void onUserConnected(IUser user) { + if (user.getTextureHash() != null && + user.getTexture() == null) { + // Request avatar data if available. + requestAvatar(user.getSession()); + } + } + + @Override + public void onUserStateUpdated(IUser user) { + if (user == null) { + return; + } + + int selfSession; + try { + selfSession = getSessionId(); + } catch (IllegalStateException e) { + Log.d(TAG, "exception in onUserStateUpdated: " + e); + return; + } + + if (user.getSession() == selfSession) { + mSettings.setMutedAndDeafened(user.isSelfMuted(), user.isSelfDeafened()); // Update settings mute/deafen state + if(mNotification != null) { + String contentText; + if (user.isSelfMuted() && user.isSelfDeafened()) + contentText = getString(R.string.status_notify_muted_and_deafened); + else if (user.isSelfMuted()) + contentText = getString(R.string.status_notify_muted); + else + contentText = getString(R.string.connected); + mNotification.setCustomContentText(contentText); + mNotification.show(); + } + } + + if (user.getTextureHash() != null && user.getTexture() == null) { + // Update avatar data if available. + requestAvatar(user.getSession()); + } + } + + @Override + public void onMessageLogged(IMessage message) { + // Split on / strip all HTML tags. + Document parsedMessage = Jsoup.parseBodyFragment(message.getMessage()); + String strippedMessage = parsedMessage.text(); + + String ttsMessage; + if(mShortTtsMessagesEnabled) { + for (Element anchor : parsedMessage.getElementsByTag("A")) { + // Get just the domain portion of links + String href = anchor.attr("href"); + // Only shorten anchors without custom text + if (href != null && href.equals(anchor.text())) { + String urlHostname = HtmlUtils.getHostnameFromLink(href); + if (urlHostname != null) { + anchor.text(getString(R.string.chat_message_tts_short_link, urlHostname)); + } + } + } + ttsMessage = parsedMessage.text(); + } else { + ttsMessage = strippedMessage; + } + + String formattedTtsMessage = getString(R.string.notification_message, + message.getActorName(), ttsMessage); + + // Read if TTS is enabled, the message is less than threshold, is a text message, and not deafened + if(mSettings.isTextToSpeechEnabled() && + mTTS != null && + formattedTtsMessage.length() <= TTS_THRESHOLD && + getSessionUser() != null && + !getSessionUser().isSelfDeafened()) { + int result = mTTS.speak(formattedTtsMessage, TextToSpeech.QUEUE_ADD, null); + if (result == TextToSpeech.ERROR) { + Log.w(TAG, "TTS engine rejected a queued message"); + } + } + + // TODO: create a customizable notification sieve + if (RadioNotificationPolicy.shouldShowChatNotification( + mSettings.isChatNotifyEnabled(), isManagedRadioDevice())) { + mMessageNotification.show(message); + } + + appendMessageLog(new IChatMessage.TextMessage(message)); + } + + @Override + public void onLogInfo(String message) { + appendMessageLog(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.INFO, + message)); + } + + @Override + public void onLogWarning(String message) { + appendMessageLog(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.WARNING, + message)); + } + + @Override + public void onLogError(String message) { + appendMessageLog(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.ERROR, + message)); + } + + @Override + public void onPermissionDenied(String reason) { + if(mNotification != null && !mSuppressNotifications) { + mNotification.show(); + } + } + + @Override + public void onUserTalkStateUpdated(IUser user) { + int selfSession = -1; + try { + selfSession = getSessionId(); + } catch (IllegalStateException e) { + Log.d(TAG, "exception in onUserTalkStateUpdated: " + e); + } + + boolean wasReceiving = mRadioReceiveTracker.isReceiving(); + mRadioReceiveTracker.update(user.getSession(), user.getName(), + user.getSession() == selfSession, user.getTalkState()); + if ((!wasReceiving && mRadioReceiveTracker.isReceiving()) + || (user.getSession() == selfSession + && user.getTalkState() != TalkState.PASSIVE)) { + wakeRadioDisplay(); + } + + if (isConnectionEstablished() && + user.getSession() == selfSession && + getTransmitMode() == Constants.TRANSMIT_PUSH_TO_TALK && + user.getTalkState() == TalkState.TALKING && + mPTTSoundEnabled) { + AudioManager audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, -1); + } + } + + @Override + public void onUserRemoved(IUser user, String reason) { + mRadioReceiveTracker.remove(user.getSession()); + } + }; + + @Override + public void onCreate() { + super.onCreate(); + sRunningService = this; + registerObserver(mObserver); + + // Register for preference changes + mSettings = Settings.getInstance(this); + mPTTSoundEnabled = RadioPttKeyManager.shouldEnablePttConfirmationSound( + RadioDeviceProfile.detectCurrent(), mSettings.isPttSoundEnabled()); + mShortTtsMessagesEnabled = mSettings.isShortTextToSpeechMessagesEnabled(); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + preferences.registerOnSharedPreferenceChangeListener(this); + + // Manually set theme to style overlay views + // XML theme does NOT do this! + setTheme(R.style.Theme_Mumla); + + mMessageLog = new ArrayList<>(MAX_MESSAGE_LOG_ENTRIES); + mMessageNotification = new MumlaMessageNotification(MumlaService.this); + + initializePttMediaSession(); + updatePttMediaSessionState(); + if (isManagedRadioDevice()) { + mMessageNotification.dismiss(); + RadioProcessWatchdog.arm(this); + mPttWatchdogHandler.postDelayed(mRadioProcessWatchdogHeartbeat, + RadioProcessWatchdog.HEARTBEAT_INTERVAL_MS); + } + if (RadioDeviceProfile.T56.equals(RadioDeviceProfile.detectCurrent())) { + mAprsTrackingManager = new AprsTrackingManager(this); + reloadTrackingConfig(); + } + + // Instantiate overlay view + mChannelOverlay = new MumlaOverlay(this); + mHotCorner = new MumlaHotCorner(this, mSettings.getHotCornerGravity(), mHotCornerListener); + + // Set up TTS + if(mSettings.isTextToSpeechEnabled()) + mTTS = new TextToSpeech(this, mTTSInitListener); + + mTalkReceiver = new TalkBroadcastReceiver(this); + } + + @Override + public IBinder onBind(Intent intent) { + return new MumlaBinder(this); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + if (ACTION_RADIO_REQUIRE_PTT_RELEASE.equals(intent.getAction())) { + requirePttRelease(); + } else if (ACTION_RADIO_PTT_RELEASED.equals(intent.getAction())) { + onTalkKeyUp(); + } else if (ACTION_RADIO_PTT_DOWN.equals(intent.getAction())) { + wakeRadioDisplay(false); + onTalkKeyDown(); + } else if (ACTION_RADIO_PTT_UP.equals(intent.getAction())) { + onTalkKeyUp(); + } else if (ACTION_RADIO_TRACKING_POLL.equals(intent.getAction()) + && mAprsTrackingManager != null) { + mAprsTrackingManager.onPoll(); + } + } + return super.onStartCommand(intent, flags, startId); + } + + @Override + public void onDestroy() { + if (sRunningService == this) { + sRunningService = null; + } + if (mAprsTrackingManager != null) { + mAprsTrackingManager.stop(); + mAprsTrackingManager = null; + } + mPttWatchdogHandler.removeCallbacks(mRadioProcessWatchdogHeartbeat); + mRadioReceiveTracker.clear(); + releasePttForSafety(true); + setPttMediaSessionActive(false); + if (mPttMediaSession != null) { + mPttMediaSession.release(); + mPttMediaSession = null; + } + if (mRadioAlertTone != null) { + mRadioAlertTone.release(); + mRadioAlertTone = null; + } + if (mRadioScreenWakeLock != null && mRadioScreenWakeLock.isHeld()) { + mRadioScreenWakeLock.release(); + } + if (mNotification != null) { + mNotification.hide(); + mNotification = null; + } + if (mReconnectNotification != null) { + mReconnectNotification.hide(); + mReconnectNotification = null; + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + preferences.unregisterOnSharedPreferenceChangeListener(this); + try { + unregisterReceiver(mTalkReceiver); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + + unregisterObserver(mObserver); + if(mTTS != null) mTTS.shutdown(); + mMessageLog = null; + mMessageNotification.dismiss(); + super.onDestroy(); + } + + @Override + public void onConnectionSynchronized() { + // TODO? We seem to be getting a RuntimeException here, from the call + // to the superclass function (in HumlaService). In there, + // mConnect.getSession() finds that isSynchronized==false and throws + // NotSynchronizedException (which is re-thrown as the + // RuntimeException). But how can it be !isSynchronized? -- A server + // msg triggers HumlaConnection.messageServerSync(), which sets up + // mSession and mSynchronized==true and then proceeds to call us from + // a Runnable post()ed to a Handler. The reason could only be that + // HumlaConnect.connect() or disconnect() is called again in the + // middle of all this? And it's made possible by the Handler? + try { + super.onConnectionSynchronized(); + } catch (RuntimeException e) { + Log.d(TAG, "exception in onConnectionSynchronized: " + e); + return; + } + + // Restore mute/deafen state + if(mSettings.isMuted() || mSettings.isDeafened()) { + setSelfMuteDeafState(mSettings.isMuted(), mSettings.isDeafened()); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK), RECEIVER_EXPORTED); + } else { + registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK)); + } + + if (mSettings.isHotCornerEnabled()) { + mHotCorner.setShown(true); + } + // Configure proximity sensor + if (mSettings.isHandsetMode()) { + setProximitySensorOn(true); + } + + updatePttMediaSessionState(); + } + + @Override + public void onConnectionDisconnected(HumlaException e) { + mRadioReceiveTracker.clear(); + mRadioRoomReady = false; + releasePttForSafety(true); + super.onConnectionDisconnected(e); + updatePttMediaSessionState(); + try { + unregisterReceiver(mTalkReceiver); + } catch (IllegalArgumentException iae) { + } + + // Remove overlay if present. + mChannelOverlay.hide(); + + mHotCorner.setShown(false); + + setProximitySensorOn(false); + + clearMessageLog(); + mMessageNotification.dismiss(); + } + + /** + * Called when the user makes a change to their preferences. + * Should update all preferences relevant to the service. + */ + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + Bundle changedExtras = new Bundle(); + boolean requiresReconnect = false; + switch (key) { + case Settings.PREF_INPUT_METHOD: + /* Convert input method defined in settings to an integer format used by Humla. */ + int inputMethod = mSettings.getHumlaInputMethod(); + changedExtras.putInt(HumlaService.EXTRAS_TRANSMIT_MODE, inputMethod); + mChannelOverlay.setPushToTalkShown(inputMethod == Constants.TRANSMIT_PUSH_TO_TALK); + updatePttMediaSessionState(); + break; + case Settings.PREF_HANDSET_MODE: + setProximitySensorOn(isConnectionEstablished() && mSettings.isHandsetMode()); + changedExtras.putInt(HumlaService.EXTRAS_AUDIO_STREAM, mSettings.isHandsetMode() ? + AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC); + break; + case Settings.PREF_THRESHOLD: + changedExtras.putFloat(HumlaService.EXTRAS_DETECTION_THRESHOLD, + mSettings.getDetectionThreshold()); + break; + case Settings.PREF_HOT_CORNER_KEY: + mHotCorner.setGravity(mSettings.getHotCornerGravity()); + mHotCorner.setShown(isConnectionEstablished() && mSettings.isHotCornerEnabled()); + break; + case Settings.PREF_USE_TTS: + if (mTTS == null && mSettings.isTextToSpeechEnabled()) + mTTS = new TextToSpeech(this, mTTSInitListener); + else if (mTTS != null && !mSettings.isTextToSpeechEnabled()) { + mTTS.shutdown(); + mTTS = null; + } + break; + case Settings.PREF_SHORT_TTS_MESSAGES: + mShortTtsMessagesEnabled = mSettings.isShortTextToSpeechMessagesEnabled(); + break; + case Settings.PREF_AMPLITUDE_BOOST: + changedExtras.putFloat(EXTRAS_AMPLITUDE_BOOST, + mSettings.getAmplitudeBoostMultiplier()); + break; + case Settings.PREF_HALF_DUPLEX: + changedExtras.putBoolean(EXTRAS_HALF_DUPLEX, mSettings.isHalfDuplex()); + break; + case Settings.PREF_PREPROCESSOR_ENABLED: + changedExtras.putBoolean(EXTRAS_ENABLE_PREPROCESSOR, + mSettings.isPreprocessorEnabled()); + break; + case Settings.PREF_ECHO_CANCELLATION_METHOD: + changedExtras.putString(EXTRAS_ECHO_CANCELLATION_METHOD, + mSettings.getEchoCancellationMethod()); + break; + case Settings.PREF_PTT_SOUND: + mPTTSoundEnabled = RadioPttKeyManager.shouldEnablePttConfirmationSound( + RadioDeviceProfile.detectCurrent(), mSettings.isPttSoundEnabled()); + break; + case Settings.PREF_INPUT_QUALITY: + changedExtras.putInt(EXTRAS_INPUT_QUALITY, mSettings.getInputQuality()); + break; + case Settings.PREF_INPUT_RATE: + changedExtras.putInt(EXTRAS_INPUT_RATE, mSettings.getInputSampleRate()); + break; + case Settings.PREF_FRAMES_PER_PACKET: + changedExtras.putInt(EXTRAS_FRAMES_PER_PACKET, mSettings.getFramesPerPacket()); + break; + case Settings.PREF_CERT_ID: + case Settings.PREF_FORCE_TCP: + case Settings.PREF_USE_TOR: + case Settings.PREF_DISABLE_OPUS: + // These are settings we flag as 'requiring reconnect'. + requiresReconnect = true; + break; + } + if (changedExtras.size() > 0) { + try { + // Reconfigure the service appropriately. + requiresReconnect |= configureExtras(changedExtras); + } catch (AudioException e) { + e.printStackTrace(); + } + } + + if (requiresReconnect && isConnectionEstablished()) { + Toast.makeText(this, R.string.change_requires_reconnect, Toast.LENGTH_LONG).show(); + } + } + + private void setProximitySensorOn(boolean on) { + if(on) { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + mProximityLock = pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "Mumla:Proximity"); + mProximityLock.acquire(); + } else { + if(mProximityLock != null) mProximityLock.release(); + mProximityLock = null; + } + } + + @Override + public void onMuteToggled() { + IUser user = getSessionUser(); + if (isConnectionEstablished() && user != null) { + boolean muted = !user.isSelfMuted(); + boolean deafened = user.isSelfDeafened() && muted; + setSelfMuteDeafState(muted, deafened); + } + } + + @Override + public void onDeafenToggled() { + IUser user = getSessionUser(); + if (isConnectionEstablished() && user != null) { + setSelfMuteDeafState(!user.isSelfDeafened(), !user.isSelfDeafened()); + } + } + + @Override + public void onOverlayToggled() { + // Ditch notification shade/panel to make overlay presence/permission request visible. + // But on Android 12 that's no longer allowed. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + Intent close = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); + getApplicationContext().sendBroadcast(close); + } + + if (!mChannelOverlay.isShown()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (!android.provider.Settings.canDrawOverlays(getApplicationContext())) { + Intent showSetting = new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName())); + showSetting.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(showSetting); + Toast.makeText(this, R.string.grant_perm_draw_over_apps, Toast.LENGTH_LONG).show(); + return; + } + } + mChannelOverlay.show(); + } else { + mChannelOverlay.hide(); + } + } + + @Override + public void onReconnectNotificationDismissed() { + mErrorShown = true; + } + + @Override + public void reconnect() { + connect(); + } + + @Override + public void cancelReconnect() { + if (mReconnectNotification != null) { + mReconnectNotification.hide(); + mReconnectNotification = null; + } + super.cancelReconnect(); + } + + @Override + public void setOverlayShown(boolean showOverlay) { + if(!mChannelOverlay.isShown()) { + mChannelOverlay.show(); + } else { + mChannelOverlay.hide(); + } + } + + @Override + public boolean isOverlayShown() { + return mChannelOverlay.isShown(); + } + + @Override + public void clearChatNotifications() { + mMessageNotification.dismiss(); + } + + @Override + public void markErrorShown() { + mErrorShown = true; + // Dismiss the reconnection prompt if a reconnection isn't in progress. + if (mReconnectNotification != null && !isReconnecting()) { + mReconnectNotification.hide(); + mReconnectNotification = null; + } + } + + @Override + public boolean isErrorShown() { + return mErrorShown; + } + + /** + * Creates the media-session bridge used for media-style hardware PTT keys. + * + * This deliberately does not claim arbitrary F1/F2 keys. Android only routes media-button + * keys through MediaSession for an ordinary application; T99's GPIO/F-key path needs a + * device-specific broadcast or privileged input integration discovered separately. + */ + private boolean isManagedRadioDevice() { + return RadioPttKeyManager.isRadioProfile(RadioDeviceProfile.detectCurrent()); + } + + @SuppressWarnings("deprecation") + private void wakeRadioDisplay() { + wakeRadioDisplay(false); + } + + private void wakeRadioDisplay(boolean connectOnPtt) { + if (!isManagedRadioDevice()) { + return; + } + if (connectOnPtt) { + RadioPttRecoveryGuard.requireRelease(); + } + long now = SystemClock.elapsedRealtime(); + if (now - mLastRadioWakeElapsedRealtime < RADIO_WAKE_COOLDOWN_MS) { + return; + } + mLastRadioWakeElapsedRealtime = now; + + PowerManager manager = (PowerManager) getSystemService(POWER_SERVICE); + if (manager != null) { + if (mRadioScreenWakeLock == null) { + mRadioScreenWakeLock = manager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK + | PowerManager.ACQUIRE_CAUSES_WAKEUP + | PowerManager.ON_AFTER_RELEASE, + "Mumla:RadioDisplay"); + } + if (!mRadioScreenWakeLock.isHeld()) { + mRadioScreenWakeLock.acquire(RADIO_WAKE_DURATION_MS); + } + } + + Intent radio = new Intent(this, RadioShellActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + if (connectOnPtt) { + radio.putExtra(RadioShellActivity.EXTRA_CONNECT_ON_PTT, true); + } + try { + startActivity(radio); + } catch (RuntimeException error) { + Log.w(TAG, "Unable to bring radio status to foreground", error); + } + } + + private void playPttFailureAlert() { + try { + if (mRadioAlertTone == null) { + mRadioAlertTone = new ToneGenerator(AudioManager.STREAM_MUSIC, 90); + } + mRadioAlertTone.startTone(ToneGenerator.TONE_SUP_ERROR, 700); + } catch (RuntimeException error) { + Log.w(TAG, "Unable to play PTT failure tone", error); + } + sendBroadcast(new Intent(ACTION_RADIO_PTT_FAILURE).setPackage(getPackageName())); + } + + private void initializePttMediaSession() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + mPttMediaSession = new MediaSession(this, TAG + ".PttMediaSession"); + mPttMediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS); + mPttMediaSession.setCallback(new MediaSession.Callback() { + @Override + public boolean onMediaButtonEvent(Intent mediaButtonIntent) { + KeyEvent keyEvent = mediaButtonIntent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); + RadioKeyDiagnostics.record(MumlaService.this, "media-session", keyEvent); + if (keyEvent == null || !isConfiguredMediaPttKey(keyEvent.getKeyCode())) { + return super.onMediaButtonEvent(mediaButtonIntent); + } + + if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) { + if (!mPttMediaKeyDown && keyEvent.getRepeatCount() == 0) { + mPttMediaKeyDown = true; + onTalkKeyDown(); + } + return true; + } + + if (keyEvent.getAction() == KeyEvent.ACTION_UP) { + if (mPttMediaKeyDown) { + mPttMediaKeyDown = false; + onTalkKeyUp(); + } + return true; + } + + return true; + } + }); + } + + /** + * Only media-style keys can reach this callback. The configured key still controls whether + * the event is treated as PTT, so ordinary media buttons are not hijacked by default. + */ + private boolean isConfiguredMediaPttKey(int keyCode) { + if (mSettings == null || !RadioPttKeyManager.isConfiguredPttKey(keyCode, mSettings)) { + return false; + } + + return RadioPttKeyManager.isMediaStyleKey(keyCode); + } + + private void updatePttMediaSessionState() { + boolean pttMode = Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod()); + boolean shouldBeActive = pttMode + && (isConnectionEstablished() || isManagedRadioDevice()); + setPttMediaSessionActive(shouldBeActive); + } + + private void setPttMediaSessionActive(boolean active) { + if (mPttMediaSession == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + + if (active == mPttMediaSession.isActive()) { + return; + } + + if (!active && mPttMediaKeyDown) { + mPttMediaKeyDown = false; + onTalkKeyUp(); + } + mPttMediaSession.setActive(active); + } + + /** True while at least one remote user is delivering audible voice. */ + public boolean isRadioReceiving() { + return mRadioReceiveTracker.isReceiving(); + } + + @Override + public List getRadioTalkers() { + return mRadioReceiveTracker.getActiveTalkers(); + } + + /** + * Called when a user presses a talk key down (i.e. when they want to talk). + * Accounts for talk logic if toggle PTT is on. + */ + @Override + public void onTalkKeyDown() { + if (mPttInputDown || mPttWatchdogLockout) { + return; + } + if (mAprsTrackingManager != null) { + mAprsTrackingManager.onPttPressed(); + } + boolean synchronizedSession = isSynchronized(); + boolean managedRadio = isManagedRadioDevice(); + boolean pttMode = Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod()); + boolean readyToTransmit = RadioPttSafetyPolicy.canStartTransmission( + synchronizedSession, pttMode, managedRadio, mRadioRoomReady); + wakeRadioDisplay(!readyToTransmit); + if (!readyToTransmit) { + playPttFailureAlert(); + return; + } + + mPttInputDown = true; + mPttFailureAlerted = false; + mPttPressStartedElapsedRealtime = SystemClock.elapsedRealtime(); + if (!mSettings.isPushToTalkToggle() && !isTalking()) { + setTalkingState(true); // Start talking + mPttWatchdogHandler.postDelayed(mPttWatchdog, MAX_PTT_SECONDS * 1000L); + mPttWatchdogHandler.postDelayed(mPttDeliveryFailureCheck, + PTT_DELIVERY_CONFIRM_MS); + } + } + + /** + * Called when a user releases a talk key (i.e. when they do not want to talk). + * Accounts for talk logic if toggle PTT is on. + */ + @Override + public void onTalkKeyUp() { + RadioPttRecoveryGuard.noteRelease(); + boolean hadNoAudioPacket = mPttInputDown && mPttPressStartedElapsedRealtime > 0L + && getLastAudioPacketSentElapsedRealtime() < mPttPressStartedElapsedRealtime; + mPttInputDown = false; + mPttPressStartedElapsedRealtime = 0L; + mPttWatchdogLockout = false; + mPttWatchdogHandler.removeCallbacks(mPttWatchdog); + mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + if(isConnectionEstablished() + && Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod())) { + if (mSettings.isPushToTalkToggle()) { + setTalkingState(!isTalking()); // Toggle talk state + } else if (isTalking()) { + setTalkingState(false); // Stop talking + } + } + if (hadNoAudioPacket && !mPttFailureAlerted) { + playPttFailureAlert(); + } + mPttFailureAlerted = false; + } + + @Override + public void requirePttRelease() { + RadioPttRecoveryGuard.requireRelease(); + releasePttForSafety(true); + } + + @Override + public void setRadioRoomReady(boolean ready) { + mRadioRoomReady = ready; + } + + /** Stops TX and clears pending watchdog work during lifecycle or connection failures. */ + private void releasePttForSafety(boolean requireRelease) { + mPttWatchdogHandler.removeCallbacks(mPttWatchdog); + mPttWatchdogHandler.removeCallbacks(mPttDeliveryFailureCheck); + mPttInputDown = false; + mPttPressStartedElapsedRealtime = 0L; + mPttFailureAlerted = false; + mPttWatchdogLockout = requireRelease; + if (isTalking()) { + setTalkingState(false); + } + } + + @Override + public List getMessageLog() { + return Collections.unmodifiableList(mMessageLog); + } + + @Override + public void clearMessageLog() { + if (mMessageLog != null) { + mMessageLog.clear(); + } + } + + private void appendMessageLog(IChatMessage message) { + appendMessageLog(mMessageLog, message); + } + + static void appendMessageLog(List messageLog, IChatMessage message) { + if (messageLog == null || message == null) { + return; + } + int overflow = messageLog.size() - MAX_MESSAGE_LOG_ENTRIES + 1; + if (overflow > 0) { + messageLog.subList(0, overflow).clear(); + } + messageLog.add(message); + } + + @Override + public void reloadTrackingConfig() { + if (mAprsTrackingManager == null + || !RadioDeviceProfile.T56.equals(RadioDeviceProfile.detectCurrent())) { + return; + } + final AprsTrackingManager manager = mAprsTrackingManager; + new Thread(() -> { + try { + manager.reloadConfig(new RadioConfigRepository(MumlaService.this) + .loadActiveOrDefault()); + } catch (Exception exception) { + Log.w(TAG, "T56 tracking config reload skipped: " + + exception.getClass().getSimpleName()); + } + }, "minimum-t56-tracking-config").start(); + } + + /** Requests a reload only when the service is already running. */ + public static void reloadTrackingConfigIfRunning() { + MumlaService service = sRunningService; + if (service != null) { + service.reloadTrackingConfig(); + } + } + + /** + * Sets whether or not notifications should be suppressed. + * + * It's typically a good idea to do this when the main activity is foreground, so that the user + * is not bombarded with redundant alerts. + * + * Chat notifications are NOT suppressed. They may be if a chat indicator is added in the + * activity itself. For now, the user may disable chat notifications manually. + * + * @param suppressNotifications true if Mumla is to disable notifications. + */ + @Override + public void setSuppressNotifications(boolean suppressNotifications) { + mSuppressNotifications = suppressNotifications; + } + + public static class MumlaBinder extends Binder { + private final MumlaService mService; + + private MumlaBinder(MumlaService service) { + mService = service; + } + + public IMumlaService getService() { + return mService; + } + } + + @Override + public Message sendUserTextMessage(int session, String message) { + Message msg = super.sendUserTextMessage(session, message); + + appendMessageLog(new IChatMessage.TextMessage(msg)); + return msg; + } + + @Override + public Message sendChannelTextMessage(int channel, String message, boolean tree) { + Message msg = super.sendChannelTextMessage(channel, message, tree); + + appendMessageLog(new IChatMessage.TextMessage(msg)); + return msg; + } +} diff --git a/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java b/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java new file mode 100644 index 00000000..0cd95f12 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/service/ipc/TalkBroadcastReceiver.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.service.ipc; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.IHumlaSession; + +/** + * Created by andrew on 08/08/14. + */ +public class TalkBroadcastReceiver extends BroadcastReceiver { + public static final String BROADCAST_TALK = "se.lublin.mumla.action.TALK"; + public static final String EXTRA_TALK_STATUS = "status"; + public static final String TALK_STATUS_ON = "on"; + public static final String TALK_STATUS_OFF = "off"; + public static final String TALK_STATUS_TOGGLE = "toggle"; + + private IHumlaService mService; + + public TalkBroadcastReceiver(IHumlaService service) { + mService = service; + } + + @Override + public void onReceive(Context context, Intent intent) { + if (BROADCAST_TALK.equals(intent.getAction())) { + if (!mService.isConnected()) + return; + IHumlaSession session = mService.HumlaSession(); + String status = intent.getStringExtra(EXTRA_TALK_STATUS); + if (status == null) status = TALK_STATUS_TOGGLE; + if (TALK_STATUS_ON.equals(status)) { + session.setTalkingState(true); + } else if (TALK_STATUS_OFF.equals(status)) { + session.setTalkingState(false); + } else if (TALK_STATUS_TOGGLE.equals(status)) { + session.setTalkingState(!session.isTalking()); + } + } else { + throw new UnsupportedOperationException(); + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/BitmapUtils.java b/app/src/main/java/se/lublin/mumla/util/BitmapUtils.java new file mode 100644 index 00000000..92af2885 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/BitmapUtils.java @@ -0,0 +1,27 @@ +package se.lublin.mumla.util; + +import android.graphics.Bitmap; + +public class BitmapUtils { + public static Bitmap resizeKeepingAspect(Bitmap image, int maxWidth, int maxHeight){ + int width = image.getWidth(); + int height = image.getHeight(); + + if (width < maxWidth && height < maxHeight) { + return image; + } + + float ratioBitmap = (float) width / (float) height; + float ratioMax = (float) maxWidth / (float) maxHeight; + + int finalWidth = maxWidth; + int finalHeight = maxHeight; + if (ratioMax > ratioBitmap) { + finalWidth = (int) ((float) maxHeight * ratioBitmap); + } else { + finalHeight = (int) ((float) maxWidth / ratioBitmap); + } + + return Bitmap.createScaledBitmap(image, finalWidth, finalHeight, true); + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/HtmlUtils.java b/app/src/main/java/se/lublin/mumla/util/HtmlUtils.java new file mode 100644 index 00000000..817cf929 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/HtmlUtils.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import android.text.TextUtils; + +import java.net.URI; + +public class HtmlUtils { + /** + * Tries to get the link's hostname, returns `null` if not a valid URL. + * @param link link to get the hostname of + * @return String? + */ + public static String getHostnameFromLink(String link) { + // Cheap pre-check + if (link.contains("://")) { + try { + URI maybeURI = URI.create(link); + if (!TextUtils.isEmpty(maybeURI.getHost())) { + return maybeURI.getHost(); + } + } catch (IllegalArgumentException e) { + // not a valid URI + } + } + return null; + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/HumlaServiceFragment.java b/app/src/main/java/se/lublin/mumla/util/HumlaServiceFragment.java new file mode 100644 index 00000000..4fee0ba8 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/HumlaServiceFragment.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import android.app.Activity; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import se.lublin.humla.IHumlaService; +import se.lublin.humla.util.IHumlaObserver; +import se.lublin.mumla.service.IMumlaService; + +/** + * Fragment class intended to make binding the Humla service to fragments easier. + * Created by andrew on 04/08/13. + */ +public abstract class HumlaServiceFragment extends Fragment { + + private HumlaServiceProvider mServiceProvider; + + /** State boolean to make sure we don't double initialize a fragment once a service has been bound. */ + private boolean mBound; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mServiceProvider = (HumlaServiceProvider) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement HumlaServiceProvider"); + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mServiceProvider.addServiceFragment(this); + if(mServiceProvider.getService() != null && !mBound) + onServiceAttached(mServiceProvider.getService()); + } + + @Override + public void onDestroy() { + mServiceProvider.removeServiceFragment(this); + if(mServiceProvider.getService() != null && mBound) + onServiceDetached(mServiceProvider.getService()); + super.onDestroy(); + } + + /** The definitive place where data from the service will be used to initialize the fragment. Only called once per bind, whether the fragment loads first or the service. */ + public void onServiceBound(IHumlaService service) { } + + public void onServiceUnbound() { } + + /** If implemented, will register the returned observer to the service upon binding. */ + public IHumlaObserver getServiceObserver() { + return null; + } + + private void onServiceAttached(IHumlaService service) { + mBound = true; + if(getServiceObserver() != null) + service.registerObserver(getServiceObserver()); + + onServiceBound(service); + } + + private void onServiceDetached(IHumlaService service) { + mBound = false; + if(getServiceObserver() != null) + service.unregisterObserver(getServiceObserver()); + + onServiceUnbound(); + } + + public void setServiceBound(boolean bound) { + if(bound && !mBound) + onServiceAttached(mServiceProvider.getService()); + else if(mBound && !bound) + onServiceDetached(mServiceProvider.getService()); + } + + public IMumlaService getService() { + return mServiceProvider.getService(); + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/HumlaServiceProvider.java b/app/src/main/java/se/lublin/mumla/util/HumlaServiceProvider.java new file mode 100644 index 00000000..0d7e1351 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/HumlaServiceProvider.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import se.lublin.mumla.service.IMumlaService; + +/** + * Created by andrew on 03/08/13. + */ +public interface HumlaServiceProvider { + IMumlaService getService(); + void addServiceFragment(HumlaServiceFragment fragment); + void removeServiceFragment(HumlaServiceFragment fragment); +} diff --git a/app/src/main/java/se/lublin/mumla/util/ModelUtils.java b/app/src/main/java/se/lublin/mumla/util/ModelUtils.java new file mode 100644 index 00000000..9b45d3dd --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/ModelUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import java.util.LinkedList; +import java.util.List; + +import se.lublin.humla.model.IChannel; + +/** + * Tools for dealing with the recursive user-channel hierarchy. + * Created by andrew on 18/10/14. + */ +public class ModelUtils { + /** + * Flattens the channel hierarchy, returning an array of channels in hierarchical order. + * @param channel The root channel to flatten from. + * @return A list of channels. + */ + public static List getChannelList(IChannel channel) { + LinkedList channels = new LinkedList(); + getChannelList(channel, channels); + return channels; + } + + private static void getChannelList(IChannel channel, List channels) { + channels.add(channel); + for (IChannel subc : channel.getSubchannels()) { + if (subc != null) { + getChannelList(subc, channels); + } + } + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/MumbleImageGetter.java b/app/src/main/java/se/lublin/mumla/util/MumbleImageGetter.java new file mode 100644 index 00000000..bff8e585 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/MumbleImageGetter.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.StrictMode; +import android.text.Html; +import android.util.Base64; +import android.util.DisplayMetrics; +import android.util.Log; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +import se.lublin.mumla.Settings; + +/** + * Implementation of ImageGetter designed for Mumble MOTDs and messages. + * Can read base64-embedded images and references. Caches them too. + * Created by andrew on 07/02/14. + */ +public class MumbleImageGetter implements Html.ImageGetter { + private static final String TAG = MumbleImageGetter.class.getName(); + + /** The maximum image size in bytes to load. */ + private static final int MAX_LENGTH = 64000; + + private Context mContext; + private Settings mSettings; + private Map mBitmapCache; + + public MumbleImageGetter(Context context) { + mContext = context; + mSettings = Settings.getInstance(context); + mBitmapCache = new HashMap(); + + // We have to enable network on the main thread here. FIXME + StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder().permitAll().build(); + StrictMode.setThreadPolicy(policy); + } + + @Override + public Drawable getDrawable(String source) { + Drawable cachedDrawable = mBitmapCache.get(source); + if(cachedDrawable != null) return cachedDrawable; + + String decodedSource; // Decode from URL encoding + try { + decodedSource = URLDecoder.decode(source, "UTF-8"); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + return null; + } + + Bitmap bitmap = null; + try { + if(decodedSource.startsWith("data:image")) { + bitmap = getBase64Image(decodedSource.split(",")[1]); + } else if(mSettings.shouldLoadExternalImages()) { + bitmap = getURLImage(decodedSource); + } + } catch (IllegalArgumentException | ArrayIndexOutOfBoundsException e) { + Log.w(TAG, "exception when decoding data:image: " + e.toString()); + return null; + } + if(bitmap == null) return null; + + BitmapDrawable drawable = new BitmapDrawable(mContext.getResources(), bitmap); + DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); // Use display metrics to scale image to mdpi + drawable.setBounds(0, 0, (int)((float)drawable.getIntrinsicWidth()*metrics.density), (int)((float)drawable.getIntrinsicHeight()*metrics.density)); + mBitmapCache.put(source, drawable); + return drawable; + } + + private Bitmap getBase64Image(String base64) throws IllegalArgumentException { + byte[] src = Base64.decode(base64, Base64.DEFAULT); + return BitmapFactory.decodeByteArray(src, 0, src.length); + } + + private Bitmap getURLImage(String source) { + try { + URL url = new URL(source); + URLConnection conn = url.openConnection(); + if(conn.getContentLength() > MAX_LENGTH) return null; + return BitmapFactory.decodeStream(conn.getInputStream()); + } catch (MalformedURLException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/MumlaTrustStore.java b/app/src/main/java/se/lublin/mumla/util/MumlaTrustStore.java new file mode 100644 index 00000000..c597cce5 --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/MumlaTrustStore.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import android.content.Context; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +/** + * Created by andrew on 05/04/14. + */ +public class MumlaTrustStore { + + private static final String STORE_FILE = "mumla-store.bks"; + private static final String STORE_PASS = ""; + private static final String STORE_FORMAT = "BKS"; + + /** + * Loads the app's trust store of certificates. + * @return A loaded KeyStore with the user's trusted certificates. + */ + public static KeyStore getTrustStore(Context context) throws CertificateException, NoSuchAlgorithmException, IOException, KeyStoreException { + KeyStore store = KeyStore.getInstance(STORE_FORMAT); + try { + FileInputStream fis = context.openFileInput(STORE_FILE); + store.load(fis, STORE_PASS.toCharArray()); + fis.close(); + } catch (FileNotFoundException e) { + store.load(null, null); + } + return store; + } + + public static void saveTrustStore(Context context, KeyStore store) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException { + FileOutputStream fos = context.openFileOutput(STORE_FILE, Context.MODE_PRIVATE); + store.store(fos, STORE_PASS.toCharArray()); + fos.close(); + } + + public static void clearTrustStore(Context context) { + context.deleteFile(STORE_FILE); + } + + /** + * Gets the app's trust store path. + * @return null if the store has not yet been initialized, or the absolute path if it has. + */ + public static String getTrustStorePath(Context context) { + File trustPath = new File(context.getFilesDir(), STORE_FILE); + if(trustPath.exists()) return trustPath.getAbsolutePath(); + return null; + } + + public static String getTrustStoreFormat() { + return STORE_FORMAT; + } + + public static String getTrustStorePassword() { + return STORE_PASS; + } +} diff --git a/app/src/main/java/se/lublin/mumla/util/TintedMenuInflater.java b/app/src/main/java/se/lublin/mumla/util/TintedMenuInflater.java new file mode 100644 index 00000000..445dc90e --- /dev/null +++ b/app/src/main/java/se/lublin/mumla/util/TintedMenuInflater.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.mumla.util; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + + +/** + * A wrapper around a {@link android.view.MenuInflater} that tints menu items to the control color + * of the action bar's theme. + * Created by andrew on 27/10/14. + */ +public class TintedMenuInflater { + private MenuInflater mInflater; + private int mTintColour; + + public TintedMenuInflater(Context context) { + this(context, new MenuInflater(context)); + } + + public TintedMenuInflater(Context context, MenuInflater inflater) { + mInflater = inflater; + TypedArray actionBarThemeArray = + context.obtainStyledAttributes(new int[]{androidx.appcompat.R.attr.actionBarStyle}); + int actionBarTheme = actionBarThemeArray.getResourceId(0, 0); + actionBarThemeArray.recycle(); + + TypedArray titleTextStyleArray = + context.obtainStyledAttributes(actionBarTheme, new int[]{androidx.appcompat.R.attr.titleTextStyle}); + int titleTextStyle = titleTextStyleArray.getResourceId(0, 0); + titleTextStyleArray.recycle(); + + TypedArray textColorArray = + context.obtainStyledAttributes(titleTextStyle, new int[]{android.R.attr.textColor}); + mTintColour = textColorArray.getColor(0, 0); + textColorArray.recycle(); + } + + public void inflate(int menuRes, Menu menu) { + mInflater.inflate(menuRes, menu); + for (int x = 0; x < menu.size(); x++) { + MenuItem item = menu.getItem(x); + tintItem(item); + } + } + + public void tintItem(MenuItem item) { + if (item.getIcon() != null) { + Drawable icon = item.getIcon().mutate(); + icon.setColorFilter(mTintColour, PorterDuff.Mode.MULTIPLY); + } + } +} diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 00000000..2549328c --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 00000000..a46bf5f9 --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat.xml new file mode 100644 index 00000000..f15df180 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable-hdpi/drawer_shadow.9.png b/app/src/main/res/drawable-hdpi/drawer_shadow.9.png new file mode 100644 index 00000000..15d10e1e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_add_dark.png b/app/src/main/res/drawable-hdpi/ic_action_add_dark.png new file mode 100644 index 00000000..81777cfa Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_add_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_add_light.png b/app/src/main/res/drawable-hdpi/ic_action_add_light.png new file mode 100644 index 00000000..6d5a174e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_add_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_audio.png b/app/src/main/res/drawable-hdpi/ic_action_audio.png new file mode 100644 index 00000000..4692b0b5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_audio.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_audio_muted.png b/app/src/main/res/drawable-hdpi/ic_action_audio_muted.png new file mode 100644 index 00000000..999f40ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_audio_muted.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_audio_on.png b/app/src/main/res/drawable-hdpi/ic_action_audio_on.png new file mode 100644 index 00000000..6025c980 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_audio_on.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_channels.png b/app/src/main/res/drawable-hdpi/ic_action_channels.png new file mode 100644 index 00000000..06da7de8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_channels.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_chat.png b/app/src/main/res/drawable-hdpi/ic_action_chat.png new file mode 100644 index 00000000..4bedebf1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_chat.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_collapsed.png b/app/src/main/res/drawable-hdpi/ic_action_collapsed.png new file mode 100644 index 00000000..2ce9fb00 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_collapsed.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_comment.png b/app/src/main/res/drawable-hdpi/ic_action_comment.png new file mode 100644 index 00000000..20069e80 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_comment.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_delete_dark.png b/app/src/main/res/drawable-hdpi/ic_action_delete_dark.png new file mode 100644 index 00000000..9634ed10 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_delete_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_delete_light.png b/app/src/main/res/drawable-hdpi/ic_action_delete_light.png new file mode 100644 index 00000000..840160d6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_delete_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_drag_handle.png b/app/src/main/res/drawable-hdpi/ic_action_drag_handle.png new file mode 100644 index 00000000..b9d59f14 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_drag_handle.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_edit_dark.png b/app/src/main/res/drawable-hdpi/ic_action_edit_dark.png new file mode 100644 index 00000000..a991c2f2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_edit_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_expanded.png b/app/src/main/res/drawable-hdpi/ic_action_expanded.png new file mode 100644 index 00000000..b295740c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_expanded.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_favourite_on.png b/app/src/main/res/drawable-hdpi/ic_action_favourite_on.png new file mode 100644 index 00000000..f1faec35 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_favourite_on.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_fullscreen.png b/app/src/main/res/drawable-hdpi/ic_action_fullscreen.png new file mode 100644 index 00000000..adc04640 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_fullscreen.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_headphones.png b/app/src/main/res/drawable-hdpi/ic_action_headphones.png new file mode 100644 index 00000000..a0ce1558 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_headphones.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_info_dark.png b/app/src/main/res/drawable-hdpi/ic_action_info_dark.png new file mode 100644 index 00000000..3ff314bc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_info_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_microphone.png b/app/src/main/res/drawable-hdpi/ic_action_microphone.png new file mode 100644 index 00000000..f7fc8226 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_microphone.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_microphone_dark.png b/app/src/main/res/drawable-hdpi/ic_action_microphone_dark.png new file mode 100644 index 00000000..2c258c1b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_microphone_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_microphone_muted.png b/app/src/main/res/drawable-hdpi/ic_action_microphone_muted.png new file mode 100644 index 00000000..32650346 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_microphone_muted.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_move.png b/app/src/main/res/drawable-hdpi/ic_action_move.png new file mode 100644 index 00000000..c7b5b93e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_move.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_quick_connect.png b/app/src/main/res/drawable-hdpi/ic_action_quick_connect.png new file mode 100644 index 00000000..c7b5b93e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_quick_connect.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_save.png b/app/src/main/res/drawable-hdpi/ic_action_save.png new file mode 100644 index 00000000..ea9b7646 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_save.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_search.png b/app/src/main/res/drawable-hdpi/ic_action_search.png new file mode 100644 index 00000000..43e3b605 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_search.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_send.png b/app/src/main/res/drawable-hdpi/ic_action_send.png new file mode 100644 index 00000000..8e0cad72 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_send.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_settings.png b/app/src/main/res/drawable-hdpi/ic_action_settings.png new file mode 100644 index 00000000..8b39858e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_sort.png b/app/src/main/res/drawable-hdpi/ic_action_sort.png new file mode 100644 index 00000000..8e6d2acc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_sort.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_user_dark.png b/app/src/main/res/drawable-hdpi/ic_action_user_dark.png new file mode 100644 index 00000000..076495d5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_user_dark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_carriage_return.png b/app/src/main/res/drawable-hdpi/ic_carriage_return.png new file mode 100644 index 00000000..29d4a401 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_carriage_return.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_drawer.png b/app/src/main/res/drawable-hdpi/ic_drawer.png new file mode 100644 index 00000000..23b76c0f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_drawer.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_normal.png b/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_normal.png new file mode 100644 index 00000000..167007ed Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_normal.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_pressed.png b/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_pressed.png new file mode 100644 index 00000000..c26d9ac7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_moreoverflow_card_dark_pressed.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_more_vert_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_more_vert_black_24dp.png new file mode 100644 index 00000000..22acc550 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_more_vert_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_registered.png b/app/src/main/res/drawable-hdpi/ic_registered.png new file mode 100644 index 00000000..129f86b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_registered.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_server_light.png b/app/src/main/res/drawable-hdpi/ic_server_light.png new file mode 100644 index 00000000..0a5bbe1a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_server_light.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_server_location.png b/app/src/main/res/drawable-hdpi/ic_server_location.png new file mode 100644 index 00000000..76ed36b6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_server_location.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_notify.png b/app/src/main/res/drawable-hdpi/ic_stat_notify.png new file mode 100644 index 00000000..4833df67 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_talking_off.png b/app/src/main/res/drawable-hdpi/ic_talking_off.png new file mode 100644 index 00000000..66e369b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_talking_off.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_user_light.png b/app/src/main/res/drawable-hdpi/ic_user_light.png new file mode 100644 index 00000000..b85ca909 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_user_light.png differ diff --git a/app/src/main/res/drawable-hdpi/list_longpressed.9.png b/app/src/main/res/drawable-hdpi/list_longpressed.9.png new file mode 100644 index 00000000..278cb4ec Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_longpressed.9.png differ diff --git a/app/src/main/res/drawable-hdpi/list_pressed.9.png b/app/src/main/res/drawable-hdpi/list_pressed.9.png new file mode 100644 index 00000000..e291743d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_pressed.9.png differ diff --git a/app/src/main/res/drawable-hdpi/list_selector_disabled.9.png b/app/src/main/res/drawable-hdpi/list_selector_disabled.9.png new file mode 100644 index 00000000..51ce17a2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_selector_disabled.9.png differ diff --git a/app/src/main/res/drawable-hdpi/list_selector_focused.9.png b/app/src/main/res/drawable-hdpi/list_selector_focused.9.png new file mode 100644 index 00000000..75c68293 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/list_selector_focused.9.png differ diff --git a/app/src/main/res/drawable-hdpi/server_card.9.png b/app/src/main/res/drawable-hdpi/server_card.9.png new file mode 100644 index 00000000..caa0b97f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/server_card.9.png differ diff --git a/app/src/main/res/drawable-mdpi/drawer_shadow.9.png b/app/src/main/res/drawable-mdpi/drawer_shadow.9.png new file mode 100644 index 00000000..0bcf246c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_add_dark.png b/app/src/main/res/drawable-mdpi/ic_action_add_dark.png new file mode 100644 index 00000000..689545bb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_add_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_add_light.png b/app/src/main/res/drawable-mdpi/ic_action_add_light.png new file mode 100644 index 00000000..a5b951a4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_add_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_audio.png b/app/src/main/res/drawable-mdpi/ic_action_audio.png new file mode 100644 index 00000000..30a42318 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_audio.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_audio_muted.png b/app/src/main/res/drawable-mdpi/ic_action_audio_muted.png new file mode 100644 index 00000000..b010262a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_audio_muted.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_audio_on.png b/app/src/main/res/drawable-mdpi/ic_action_audio_on.png new file mode 100644 index 00000000..6dcfd65e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_audio_on.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_channels.png b/app/src/main/res/drawable-mdpi/ic_action_channels.png new file mode 100644 index 00000000..0af96112 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_channels.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_chat.png b/app/src/main/res/drawable-mdpi/ic_action_chat.png new file mode 100644 index 00000000..0e95c15b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_chat.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_collapsed.png b/app/src/main/res/drawable-mdpi/ic_action_collapsed.png new file mode 100644 index 00000000..c00e3270 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_collapsed.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_comment.png b/app/src/main/res/drawable-mdpi/ic_action_comment.png new file mode 100644 index 00000000..b4918019 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_comment.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_delete_dark.png b/app/src/main/res/drawable-mdpi/ic_action_delete_dark.png new file mode 100644 index 00000000..7df4d2e8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_delete_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_delete_light.png b/app/src/main/res/drawable-mdpi/ic_action_delete_light.png new file mode 100644 index 00000000..3adf8ca7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_delete_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_drag_handle.png b/app/src/main/res/drawable-mdpi/ic_action_drag_handle.png new file mode 100644 index 00000000..4d8596c9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_drag_handle.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_edit_dark.png b/app/src/main/res/drawable-mdpi/ic_action_edit_dark.png new file mode 100644 index 00000000..a2b88081 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_edit_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_expanded.png b/app/src/main/res/drawable-mdpi/ic_action_expanded.png new file mode 100644 index 00000000..908a3f02 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_expanded.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_favourite_on.png b/app/src/main/res/drawable-mdpi/ic_action_favourite_on.png new file mode 100644 index 00000000..332a9ea5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_favourite_on.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_fullscreen.png b/app/src/main/res/drawable-mdpi/ic_action_fullscreen.png new file mode 100644 index 00000000..ae9dc214 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_fullscreen.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_headphones.png b/app/src/main/res/drawable-mdpi/ic_action_headphones.png new file mode 100644 index 00000000..fec864a4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_headphones.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_info_dark.png b/app/src/main/res/drawable-mdpi/ic_action_info_dark.png new file mode 100644 index 00000000..b2248fab Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_info_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_microphone.png b/app/src/main/res/drawable-mdpi/ic_action_microphone.png new file mode 100644 index 00000000..4f0b9eac Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_microphone.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_microphone_dark.png b/app/src/main/res/drawable-mdpi/ic_action_microphone_dark.png new file mode 100644 index 00000000..5ce6b97a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_microphone_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_microphone_muted.png b/app/src/main/res/drawable-mdpi/ic_action_microphone_muted.png new file mode 100644 index 00000000..46f5ed9b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_microphone_muted.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_move.png b/app/src/main/res/drawable-mdpi/ic_action_move.png new file mode 100644 index 00000000..182d543e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_move.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_quick_connect.png b/app/src/main/res/drawable-mdpi/ic_action_quick_connect.png new file mode 100644 index 00000000..182d543e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_quick_connect.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_save.png b/app/src/main/res/drawable-mdpi/ic_action_save.png new file mode 100644 index 00000000..f06f25f7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_save.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_search.png b/app/src/main/res/drawable-mdpi/ic_action_search.png new file mode 100644 index 00000000..ee9177fe Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_search.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_send.png b/app/src/main/res/drawable-mdpi/ic_action_send.png new file mode 100644 index 00000000..879eb5be Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_send.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_settings.png b/app/src/main/res/drawable-mdpi/ic_action_settings.png new file mode 100644 index 00000000..aa3ee0cf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_sort.png b/app/src/main/res/drawable-mdpi/ic_action_sort.png new file mode 100644 index 00000000..2781db80 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_sort.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_user_dark.png b/app/src/main/res/drawable-mdpi/ic_action_user_dark.png new file mode 100644 index 00000000..720e9921 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_user_dark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_carriage_return.png b/app/src/main/res/drawable-mdpi/ic_carriage_return.png new file mode 100644 index 00000000..48aa8841 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_carriage_return.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_drawer.png b/app/src/main/res/drawable-mdpi/ic_drawer.png new file mode 100644 index 00000000..4c22f110 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_drawer.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_normal.png b/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_normal.png new file mode 100644 index 00000000..4d765ce4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_normal.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_pressed.png b/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_pressed.png new file mode 100644 index 00000000..53af347b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_moreoverflow_card_dark_pressed.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_more_vert_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_more_vert_black_24dp.png new file mode 100644 index 00000000..0e4f2f6e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_more_vert_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_registered.png b/app/src/main/res/drawable-mdpi/ic_registered.png new file mode 100644 index 00000000..06fd2da7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_registered.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_server_light.png b/app/src/main/res/drawable-mdpi/ic_server_light.png new file mode 100644 index 00000000..63e894f3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_server_light.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_server_location.png b/app/src/main/res/drawable-mdpi/ic_server_location.png new file mode 100644 index 00000000..fa12b749 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_server_location.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_notify.png b/app/src/main/res/drawable-mdpi/ic_stat_notify.png new file mode 100644 index 00000000..f4cffa08 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_talking_off.png b/app/src/main/res/drawable-mdpi/ic_talking_off.png new file mode 100644 index 00000000..aa80f140 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_talking_off.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_user_light.png b/app/src/main/res/drawable-mdpi/ic_user_light.png new file mode 100644 index 00000000..8fe4d018 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_user_light.png differ diff --git a/app/src/main/res/drawable-mdpi/list_longpressed.9.png b/app/src/main/res/drawable-mdpi/list_longpressed.9.png new file mode 100644 index 00000000..4e76f9fc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_longpressed.9.png differ diff --git a/app/src/main/res/drawable-mdpi/list_pressed.9.png b/app/src/main/res/drawable-mdpi/list_pressed.9.png new file mode 100644 index 00000000..f89c84ec Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_pressed.9.png differ diff --git a/app/src/main/res/drawable-mdpi/list_selector_disabled.9.png b/app/src/main/res/drawable-mdpi/list_selector_disabled.9.png new file mode 100644 index 00000000..a84bec54 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_selector_disabled.9.png differ diff --git a/app/src/main/res/drawable-mdpi/list_selector_focused.9.png b/app/src/main/res/drawable-mdpi/list_selector_focused.9.png new file mode 100644 index 00000000..17278b2a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/list_selector_focused.9.png differ diff --git a/app/src/main/res/drawable-mdpi/server_card.9.png b/app/src/main/res/drawable-mdpi/server_card.9.png new file mode 100644 index 00000000..c216426e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/server_card.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png b/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png new file mode 100644 index 00000000..396343f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/drawer_shadow.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_add_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_add_dark.png new file mode 100644 index 00000000..5587e869 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_add_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_add_light.png b/app/src/main/res/drawable-xhdpi/ic_action_add_light.png new file mode 100644 index 00000000..eb2379cf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_add_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_audio.png b/app/src/main/res/drawable-xhdpi/ic_action_audio.png new file mode 100644 index 00000000..cd7219f8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_audio.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_audio_muted.png b/app/src/main/res/drawable-xhdpi/ic_action_audio_muted.png new file mode 100644 index 00000000..060d490a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_audio_muted.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_bad.png b/app/src/main/res/drawable-xhdpi/ic_action_bad.png new file mode 100644 index 00000000..73b55c1b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_bad.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_channels.png b/app/src/main/res/drawable-xhdpi/ic_action_channels.png new file mode 100644 index 00000000..3e63e96e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_channels.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_chat.png b/app/src/main/res/drawable-xhdpi/ic_action_chat.png new file mode 100644 index 00000000..7bedfdc4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_chat.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_collapsed.png b/app/src/main/res/drawable-xhdpi/ic_action_collapsed.png new file mode 100644 index 00000000..11b173bf Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_collapsed.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_comment.png b/app/src/main/res/drawable-xhdpi/ic_action_comment.png new file mode 100644 index 00000000..03362241 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_comment.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_delete_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_delete_dark.png new file mode 100644 index 00000000..841be4f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_delete_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_delete_light.png b/app/src/main/res/drawable-xhdpi/ic_action_delete_light.png new file mode 100644 index 00000000..6408a855 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_delete_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_drag_handle.png b/app/src/main/res/drawable-xhdpi/ic_action_drag_handle.png new file mode 100644 index 00000000..7c665516 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_drag_handle.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_edit_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_edit_dark.png new file mode 100644 index 00000000..f437bdb9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_edit_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_error.png b/app/src/main/res/drawable-xhdpi/ic_action_error.png new file mode 100644 index 00000000..32e4b021 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_error.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_expanded.png b/app/src/main/res/drawable-xhdpi/ic_action_expanded.png new file mode 100644 index 00000000..04425ef3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_expanded.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_favourite_on.png b/app/src/main/res/drawable-xhdpi/ic_action_favourite_on.png new file mode 100644 index 00000000..96c274d2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_favourite_on.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_fullscreen.png b/app/src/main/res/drawable-xhdpi/ic_action_fullscreen.png new file mode 100644 index 00000000..1aafb04f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_fullscreen.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_headphones.png b/app/src/main/res/drawable-xhdpi/ic_action_headphones.png new file mode 100644 index 00000000..9855b9e1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_headphones.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_info_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_info_dark.png new file mode 100644 index 00000000..36265fe5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_info_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_match.png b/app/src/main/res/drawable-xhdpi/ic_action_match.png new file mode 100644 index 00000000..8dbc4812 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_match.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_microphone.png b/app/src/main/res/drawable-xhdpi/ic_action_microphone.png new file mode 100644 index 00000000..ab024d7e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_microphone.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_microphone_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_microphone_dark.png new file mode 100644 index 00000000..be8d723e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_microphone_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_microphone_muted.png b/app/src/main/res/drawable-xhdpi/ic_action_microphone_muted.png new file mode 100644 index 00000000..2a7ebc4b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_microphone_muted.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_move.png b/app/src/main/res/drawable-xhdpi/ic_action_move.png new file mode 100644 index 00000000..baf2a812 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_move.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_quick_connect.png b/app/src/main/res/drawable-xhdpi/ic_action_quick_connect.png new file mode 100644 index 00000000..baf2a812 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_quick_connect.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_save.png b/app/src/main/res/drawable-xhdpi/ic_action_save.png new file mode 100644 index 00000000..1a3b2e38 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_save.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_search.png b/app/src/main/res/drawable-xhdpi/ic_action_search.png new file mode 100644 index 00000000..77b65db9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_search.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_send.png b/app/src/main/res/drawable-xhdpi/ic_action_send.png new file mode 100644 index 00000000..02ae0cbb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_send.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_settings.png b/app/src/main/res/drawable-xhdpi/ic_action_settings.png new file mode 100644 index 00000000..b65d6d0e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_settings.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_sort.png b/app/src/main/res/drawable-xhdpi/ic_action_sort.png new file mode 100644 index 00000000..8bd8d12a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_sort.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_user_dark.png b/app/src/main/res/drawable-xhdpi/ic_action_user_dark.png new file mode 100644 index 00000000..172c0d94 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_user_dark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_carriage_return.png b/app/src/main/res/drawable-xhdpi/ic_carriage_return.png new file mode 100644 index 00000000..b9bdee96 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_carriage_return.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_drawer.png b/app/src/main/res/drawable-xhdpi/ic_drawer.png new file mode 100644 index 00000000..967bb1d5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_drawer.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_normal.png b/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_normal.png new file mode 100644 index 00000000..a78d349b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_normal.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_pressed.png b/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_pressed.png new file mode 100644 index 00000000..271b50de Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_moreoverflow_card_dark_pressed.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_more_vert_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_more_vert_black_24dp.png new file mode 100644 index 00000000..9f10aa27 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_more_vert_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_registered.png b/app/src/main/res/drawable-xhdpi/ic_registered.png new file mode 100644 index 00000000..f4f41bd6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_registered.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_server_light.png b/app/src/main/res/drawable-xhdpi/ic_server_light.png new file mode 100644 index 00000000..6dc44fd1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_server_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_server_location.png b/app/src/main/res/drawable-xhdpi/ic_server_location.png new file mode 100644 index 00000000..549b9ac6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_server_location.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_notify.png b/app/src/main/res/drawable-xhdpi/ic_stat_notify.png new file mode 100644 index 00000000..4b3117ea Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_talking_off.png b/app/src/main/res/drawable-xhdpi/ic_talking_off.png new file mode 100644 index 00000000..ec9fe90b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_talking_off.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_user_light.png b/app/src/main/res/drawable-xhdpi/ic_user_light.png new file mode 100644 index 00000000..f87cf572 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_user_light.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_longpressed.9.png b/app/src/main/res/drawable-xhdpi/list_longpressed.9.png new file mode 100644 index 00000000..dee5deb0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_longpressed.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_pressed.9.png b/app/src/main/res/drawable-xhdpi/list_pressed.9.png new file mode 100644 index 00000000..4f5fad14 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_pressed.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_selector_disabled.9.png b/app/src/main/res/drawable-xhdpi/list_selector_disabled.9.png new file mode 100644 index 00000000..57fb8a8e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_selector_disabled.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/list_selector_focused.9.png b/app/src/main/res/drawable-xhdpi/list_selector_focused.9.png new file mode 100644 index 00000000..3cf2bdd4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/list_selector_focused.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/server_card.9.png b/app/src/main/res/drawable-xhdpi/server_card.9.png new file mode 100644 index 00000000..c65d9f53 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/server_card.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_audio.png b/app/src/main/res/drawable-xxhdpi/ic_action_audio.png new file mode 100644 index 00000000..ac9c9559 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_audio.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_audio_muted.png b/app/src/main/res/drawable-xxhdpi/ic_action_audio_muted.png new file mode 100644 index 00000000..d3f4577d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_audio_muted.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_chat.png b/app/src/main/res/drawable-xxhdpi/ic_action_chat.png new file mode 100644 index 00000000..445fb7fc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_chat.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_collapsed.png b/app/src/main/res/drawable-xxhdpi/ic_action_collapsed.png new file mode 100644 index 00000000..c7ea568f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_collapsed.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_drag_handle.png b/app/src/main/res/drawable-xxhdpi/ic_action_drag_handle.png new file mode 100644 index 00000000..65a3ec38 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_drag_handle.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_expanded.png b/app/src/main/res/drawable-xxhdpi/ic_action_expanded.png new file mode 100644 index 00000000..e5e2742a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_expanded.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_headphones.png b/app/src/main/res/drawable-xxhdpi/ic_action_headphones.png new file mode 100644 index 00000000..aecc5764 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_headphones.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_microphone.png b/app/src/main/res/drawable-xxhdpi/ic_action_microphone.png new file mode 100644 index 00000000..36208653 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_microphone.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_microphone_dark.png b/app/src/main/res/drawable-xxhdpi/ic_action_microphone_dark.png new file mode 100644 index 00000000..28b84844 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_microphone_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_microphone_muted.png b/app/src/main/res/drawable-xxhdpi/ic_action_microphone_muted.png new file mode 100644 index 00000000..87206ee9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_microphone_muted.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_move.png b/app/src/main/res/drawable-xxhdpi/ic_action_move.png new file mode 100644 index 00000000..4b68ec9b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_move.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_quick_connect.png b/app/src/main/res/drawable-xxhdpi/ic_action_quick_connect.png new file mode 100644 index 00000000..4b68ec9b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_quick_connect.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_24dp.png new file mode 100644 index 00000000..94d5ab98 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more_vert_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png b/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png new file mode 100644 index 00000000..8c80b9dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_notify.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_talking_off.png b/app/src/main/res/drawable-xxhdpi/ic_talking_off.png new file mode 100644 index 00000000..923dafdd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_talking_off.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_more_vert_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_more_vert_black_24dp.png new file mode 100644 index 00000000..4642a3b6 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_more_vert_black_24dp.png differ diff --git a/app/src/main/res/drawable/ic_card_overflow.xml b/app/src/main/res/drawable/ic_card_overflow.xml new file mode 100644 index 00000000..8caf1360 --- /dev/null +++ b/app/src/main/res/drawable/ic_card_overflow.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..e5893ffd --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mumla.xml b/app/src/main/res/drawable/ic_mumla.xml new file mode 100644 index 00000000..91a29724 --- /dev/null +++ b/app/src/main/res/drawable/ic_mumla.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/outline_circle.xml b/app/src/main/res/drawable/outline_circle.xml new file mode 100644 index 00000000..9a5fb33f --- /dev/null +++ b/app/src/main/res/drawable/outline_circle.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_deafened.xml b/app/src/main/res/drawable/outline_circle_deafened.xml new file mode 100644 index 00000000..ef4f9ebb --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_deafened.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_muted.xml b/app/src/main/res/drawable/outline_circle_muted.xml new file mode 100644 index 00000000..5ea80985 --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_muted.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_server_deafened.xml b/app/src/main/res/drawable/outline_circle_server_deafened.xml new file mode 100644 index 00000000..fa0a3445 --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_server_deafened.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_server_muted.xml b/app/src/main/res/drawable/outline_circle_server_muted.xml new file mode 100644 index 00000000..a9943898 --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_server_muted.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_suppressed.xml b/app/src/main/res/drawable/outline_circle_suppressed.xml new file mode 100644 index 00000000..650df2df --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_suppressed.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_talking_off.xml b/app/src/main/res/drawable/outline_circle_talking_off.xml new file mode 100644 index 00000000..19c148d9 --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_talking_off.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_circle_talking_on.xml b/app/src/main/res/drawable/outline_circle_talking_on.xml new file mode 100644 index 00000000..e03e81a8 --- /dev/null +++ b/app/src/main/res/drawable/outline_circle_talking_on.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/overlay_background.xml b/app/src/main/res/drawable/overlay_background.xml new file mode 100644 index 00000000..97b87dbb --- /dev/null +++ b/app/src/main/res/drawable/overlay_background.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ptt_button_tint.xml b/app/src/main/res/drawable/ptt_button_tint.xml new file mode 100644 index 00000000..92189f44 --- /dev/null +++ b/app/src/main/res/drawable/ptt_button_tint.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ptt_indicator_background.xml b/app/src/main/res/drawable/ptt_indicator_background.xml new file mode 100644 index 00000000..ae1b88dd --- /dev/null +++ b/app/src/main/res/drawable/ptt_indicator_background.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_channel.xml b/app/src/main/res/layout-sw600dp/fragment_channel.xml new file mode 100644 index 00000000..74860ddd --- /dev/null +++ b/app/src/main/res/layout-sw600dp/fragment_channel.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +