diff --git a/.claude/skills/add-strings/SKILL.md b/.claude/skills/add-strings/SKILL.md new file mode 100644 index 00000000..2cc855cb --- /dev/null +++ b/.claude/skills/add-strings/SKILL.md @@ -0,0 +1,97 @@ +--- +name: add-strings +description: Add or back-fill user-facing Android strings across all locales in OpenTagViewer. Use whenever adding, renaming or translating any string that appears in the UI, or when checking translations are complete before a PR. +--- + +# Adding user-facing strings + +The app ships ten locales. A string missing from one silently falls back to English — nothing +fails, and it looks correct in whichever language you happen to read. So every UI string has +to land in every locale in one go. + +## The rule that matters + +**Never pass translations through a shell argument.** Write a JSON file with the Write tool, +then point the script at it. Passing non-ASCII text through `bash -c`, `sed` or a here-doc has +corrupted it twice in this repo — a French apostrophe reached the screen as a literal +`\’`. The Write tool does not go through shell quoting; a `for` loop does. + +For the same reason, do not hand-edit ten `strings.xml` files with ten Edit calls unless the +script cannot do what you need. It is slower and it is what the script exists to replace. + +## Workflow + +1. Find out which locales are required — do not assume, they are discovered from the tree: + + ```bash + python scripts/add_strings.py --locales + ``` + +2. Write the JSON with the **Write** tool, one entry per string, every locale present: + + ```json + { + "how_do_i_get_the_zip": { + "default": "How do I get the zip?", + "en": "How do I get the zip?", + "de": "Wie erhalte ich die ZIP-Datei?", + "fr": "Comment obtenir l’archive zip ?", + "nl": "Hoe kom ik aan de zip?", + "ru": "Как получить zip-архив?", + "ja": "zip ファイルの入手方法", + "ko": "zip 파일은 어떻게 받나요?", + "zh-rCN": "如何获取 zip 文件?", + "zh-rTW": "如何取得 zip 檔案?" + } + } + ``` + + Put it in the scratchpad or `tmp/`, not in the repo root. + +3. Apply, then confirm nothing drifted: + + ```bash + python scripts/add_strings.py tmp/new_strings.json + python scripts/add_strings.py --check + ``` + +4. Build. `aapt` is the real judge of whether the XML is acceptable: + + ```bash + JAVA_HOME='C:\Program Files\Android\Android Studio\jbr' ./gradlew.bat :app:assembleDebug + ``` + +## Flags + +| Command | What it does | +| --- | --- | +| `add_strings.py` (no args) | Prints full usage and the input format | +| `--locales` | Lists discovered locales and their files | +| `` | Adds strings; **errors** if any locale is missing or the name already exists | +| `--fill ` | Adds only where missing — for back-filling what `--check` reported | +| `--check` | Fails if any locale lacks a string the default locale has. Run before a PR | + +## Translating + +Translate properly rather than leaving English in place; the existing files are fully +translated and a stray English string is conspicuous. Points that have come up here: + +- French uses a space before `:`, `?` and `!`, and reads better with `’` than `'` +- CJK wants the full-width colon `:` and, for a gap, the ideographic space ` ` +- Keep positional format specifiers (`%1$d`, `%2$s`) intact and in a natural order for the + language — they may be reordered, which is the point of the positional form +- Product names (`Google Maps`, `AMap`, `Anisette`) generally stay as they are + +## What the script already handles + +Do not do these by hand: + +- Escaping `'`, `"` and `&` as Android requires +- Preserving ``, ``, `` so emphasis survives — styling that belongs to the copy + belongs in the resource, not in a paint flag in code that would only ever apply to English +- Re-parsing each file after writing, so a bad write fails immediately instead of at aapt time +- `translatable="false"` — set `"translatable": false` and supply only `"default"` + +## Related + +`AGENTS.md` documents this generically for any agent. Keep the two in step if the tool changes. diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..ed8930a0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Fast checks that catch the mistakes which are silent otherwise. +# +# Deliberately excludes anything slow. Builds, instrumented tests and the emulator belong in +# CI: a pre-commit hook that takes a minute gets bypassed with --no-verify within a day, and +# a hook people routinely bypass is worse than no hook, because it creates the impression of +# a safety net that is not there. +# +# Install once per clone (hooks are not versioned, so this cannot be automatic): +# +# git config core.hooksPath .githooks +# +# Skip deliberately when you need to: git commit --no-verify +# +set -uo pipefail + +# Repo root, so the hook works from any subdirectory. +cd "$(git rev-parse --show-toplevel)" || exit 1 + +failed=0 + +say() { printf '\n[pre-commit] %s\n' "$*"; } + +python_bin() { + if command -v python >/dev/null 2>&1; then printf 'python' + elif command -v python3 >/dev/null 2>&1; then printf 'python3' + else printf '' + fi +} + +PYTHON="$(python_bin)" +if [[ -z "$PYTHON" ]]; then + say "No python on PATH; skipping checks." + exit 0 +fi + +staged() { + git diff --cached --name-only --diff-filter=ACMR +} + +# --------------------------------------------------------------------------------------- +# Translations. +# +# A string missing from one locale changes nothing observable: the build passes and the app +# reads correctly to anyone who speaks the language it was written in. It surfaces as a user +# reporting English text in an otherwise translated app, which is why it is worth catching +# at the point the string is added rather than weeks later. +# --------------------------------------------------------------------------------------- +if staged | grep -q "app/src/main/res/values.*/strings.xml"; then + say "strings.xml changed - checking every locale has every string" + if ! "$PYTHON" scripts/add_strings.py --check; then + printf '\n Fix with: python scripts/add_strings.py --fill \n' + printf ' See .claude/skills/add-strings/SKILL.md or AGENTS.md\n' + failed=1 + fi +fi + +# --------------------------------------------------------------------------------------- +# Python: lint and type-check only what is being committed, so the hook stays quick. +# --------------------------------------------------------------------------------------- +# Scoped to the same trees CI type-checks. python/ (the macOS wizard) has pre-existing +# pyright errors and is excluded there too; a hook stricter than CI would block commits on +# debt the author did not create, and the first thing anyone learns from that is --no-verify. +changed_python="$(staged | grep -E '^(scripts|app/src/main/python)/.*\.py$' || true)" + +if [[ -n "$changed_python" ]]; then + if "$PYTHON" -m flake8 --version >/dev/null 2>&1; then + say "Linting changed Python with flake8" + # shellcheck disable=SC2086 + "$PYTHON" -m flake8 $changed_python || failed=1 + fi + + if "$PYTHON" -m pyright --version >/dev/null 2>&1; then + say "Type-checking changed Python with pyright" + # shellcheck disable=SC2086 + "$PYTHON" -m pyright $changed_python || failed=1 + fi +fi + +# --------------------------------------------------------------------------------------- +# Secrets. secrets.properties holds a real Google Maps key and is gitignored; committing it +# would publish the key, and a published key cannot be unpublished, only rotated. +# --------------------------------------------------------------------------------------- +if staged | grep -qx "secrets.properties"; then + say "REFUSING: secrets.properties is staged. It holds a real API key and is gitignored." + failed=1 +fi + +if (( failed )); then + printf '\n[pre-commit] Checks failed. Commit with --no-verify to override.\n\n' + exit 1 +fi + +exit 0 diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 441fd1b9..4194d906 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -18,6 +18,10 @@ on: - "gradle.bat" - "local.defaults.properties" - "settings.gradle.kts" + # The build shells out to these: the unicorn stub wheel is generated by + # build_unicorn_stub_wheel.py, so a change here can break the build without + # touching anything else in this list. + - "scripts/**" pull_request: branches: [ "main" ] paths: @@ -29,11 +33,123 @@ on: - "gradle.bat" - "local.defaults.properties" - "settings.gradle.kts" + # The build shells out to these: the unicorn stub wheel is generated by + # build_unicorn_stub_wheel.py, so a change here can break the build without + # touching anything else in this list. + - "scripts/**" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: + # Cheap and dependency-free, so it runs on its own and reports in seconds rather than + # after an emulator boot. A string missing from a locale fails nothing on its own - the + # build succeeds and the app looks correct to anyone reading the language it was written + # in - so without this it is only ever found by a user seeing English. + static-checks: + name: Static checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check every locale has every string + run: python scripts/add_strings.py --check + + # These scripts gate other checks - add_strings.py gates the step above, and + # exporter_version.py gates the exporter release - so a bug in one of them reports + # green while protecting nothing. + - name: Test the tooling in scripts/ + run: | + python -m pip install pytest + python -m pytest scripts/test -v + + # pyright rather than mypy: the editors used on this project run Pylance, which is + # pyright, and mypy's defaults are lenient enough to pass code Pylance flags. + # + # Scoped to the two clean trees. python/ (the macOS wizard) has pre-existing errors and + # needs its own cleanup before it can be added. + # + # main.py imports findmy and NSKeyedUnArchiver, so those have to be installed first or + # every one of them is reported as an unresolved import and the check means nothing. + # + # Run through pip rather than an action so this is the identical command CONTRIBUTING.md + # tells contributors to run locally. + # Pillow is only used by make_debug_launcher_icon.py, and that script degrades + # gracefully without it. Installed rather than silenced so pyright actually checks the + # file instead of skipping it. + - name: Type-check Python with pyright + run: | + python -m pip install pyright==1.1.411 pillow + python -m pip install -r app/src/test/python/requirements.txt + python -m pyright app/src/main/python scripts + + # The Room v1 -> v2 migration can only be exercised on a real Android runtime, so it + # lives in src/androidTest and needs an emulator. `./gradlew test` in the build job + # below runs JVM unit tests only and would silently skip it - which matters because a + # broken migration destroys every existing user's imported beacons and location + # history, and only reproduces on upgrade, never on a fresh install. + instrumented-tests: + name: Instrumented tests (emulator) + environment: 'Android Build' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set Up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + + - name: Inject secrets.properties + run: | + echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + + # KVM is required for a hardware-accelerated emulator; without it the run times out. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + ram-size: 4096M + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: ./gradlew connectedDebugAndroidTest --console=plain + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-reports + path: app/build/reports/androidTests/ + if-no-files-found: ignore + build: environment: 'Android Build' runs-on: ubuntu-latest @@ -66,10 +182,26 @@ jobs: run: | echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY || 'maps_key_default_value' }}" > secrets.properties + # No stub wheel to validate any more: it is generated from app/stubs/unicorn/ by + # generateUnicornStubWheel during the build, rather than being checked in. + # Run Tests Build - name: Run gradle tests run: ./gradlew test + # Covers app/src/main/python (the module Chaquopy packages into the APK). + # Runs on plain CPython - it imports no Android or Java types. + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Chaquopy bridge test dependencies + run: python -m pip install -r app/src/test/python/requirements.txt + + - name: Run Chaquopy bridge tests + run: python -m pytest app/src/test/python -v + # Create APK Debug - name: Build Debug APK - ${{ env.main_project_module }} module run: ./gradlew testDebugUnitTest assembleDebug diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index d6b1f8a3..c6cd849f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -80,9 +80,29 @@ jobs: run: | echo "${{ secrets.SIGNING_KEY }}" | base64 --decode > ${{ env.main_project_module }}/${{ env.KEYSTORE_FILE }} + # No stub wheel to validate any more: it is generated from app/stubs/unicorn/ by + # generateUnicornStubWheel during the build, rather than being checked in. + + # A release is the worst time to discover a locale is missing a string, since the + # fallback is silent: those users simply get English. + - name: Check every locale has every string + run: python3 scripts/add_strings.py --check + - name: Run gradle tests run: ./gradlew test + # Covers app/src/main/python, the module Chaquopy packages into the APK. + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Chaquopy bridge test dependencies + run: python -m pip install -r app/src/test/python/requirements.txt + + - name: Run Chaquopy bridge tests + run: python -m pytest app/src/test/python -v + - name: Build gradle project run: ./gradlew build -x lint env: diff --git a/.github/workflows/macos-exporter-python.yml b/.github/workflows/macos-exporter-python.yml index 626701f1..4b480f87 100644 --- a/.github/workflows/macos-exporter-python.yml +++ b/.github/workflows/macos-exporter-python.yml @@ -22,10 +22,31 @@ jobs: && (github.event.release.prerelease == true || github.event.release.published_at != null) && startsWith(github.event.release.tag_name, 'macos-exporter-v') + # The version both build jobs name their artifacts with. It comes from here rather than + # from each job parsing the tag again, so there is exactly one place it can be wrong. + outputs: + app-version: ${{ steps.version.outputs.APP_VERSION }} + steps: # (reuses steps from full flow!) - name: Checkout code uses: actions/checkout@v4 + # Nothing in this pipeline rewrites VERSION in python/main/wizard.py: the tag names the + # artifact, and the app keeps whatever was committed. So a tag that disagrees with the + # source ships a build that calls itself the old version in its title bar and in the + # `via:` field of every export it writes. This step is what makes that a failed release + # instead of a confusing zip. It runs before either build job (they `need` this one), so + # a mismatch costs one minute rather than two full PyInstaller builds. + # + # Deliberately not the reverse - patching the tag into the source - because the wizard + # also runs from source, and those exports stamp `via:` too. See scripts/exporter_version.py. + - name: Check the release tag matches the version in the source + id: version + run: | + APP_VERSION="$(python3 scripts/exporter_version.py --tag "${{ github.ref }}")" + echo "Tag and source agree on version: $APP_VERSION" + echo "APP_VERSION=$APP_VERSION" >> $GITHUB_OUTPUT + # Do everything strictly for the python version used in the release build... - name: Set up Python ${{ env.PYINSTALLER_PYTHON_VER }} uses: actions/setup-python@v3 @@ -83,30 +104,13 @@ jobs: && (github.event.release.prerelease == true || github.event.release.published_at != null) && startsWith(github.event.release.tag_name, 'macos-exporter-v') - steps: - - name: Extract Version from Tag - id: extract_version - run: | - # GITHUB_REF for tags is like 'refs/tags/macos-exporter-v1.0.3.1' - FULL_TAG_NAME="${{ github.ref }}" - - # Remove 'refs/tags/' prefix to get 'macos-exporter-v1.0.3.1' - TAG_WITHOUT_PREFIX="${FULL_TAG_NAME##refs/tags/}" - - # Find the index of '-v' - # This will be 'macos-exporter-' part, then we add 2 to skip '-v' - START_INDEX=$(( $(echo "$TAG_WITHOUT_PREFIX" | awk -F'-v' '{print length($1)}') + 2 )) - - # Extract everything from '-v' onwards - # Using cut or substring if available, or just awk - VERSION_STRING=$(echo "$TAG_WITHOUT_PREFIX" | cut -c "$START_INDEX"-) - - # Output the extracted version as a step output - echo "Extracted version: $VERSION_STRING" - - # !! this is reused below - echo "APP_VERSION=$VERSION_STRING" >> $GITHUB_OUTPUT + # The version is not parsed out of the tag here. It is whatever test-release-version + # confirmed the tag and python/main/wizard.py agree on, so the zip name, the release + # name, and the version the app reports about itself cannot come apart. + env: + APP_VERSION: ${{ needs.test-release-version.outputs.app-version }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -137,8 +141,8 @@ jobs: - name: Prepare and Upload MacOS App working-directory: ./python run: | - echo "Going to build zip with version: ${{ steps.extract_version.outputs.APP_VERSION }}" - APP_ZIP_NAME="${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip" + echo "Going to build zip with version: $APP_VERSION" + APP_ZIP_NAME="${{ env.APP_NAME }}-$APP_VERSION-${{ runner.arch }}.zip" cd ./dist zip -r $APP_ZIP_NAME OpenTagViewer.app/ OpenTagViewer mkdir -p ${{ github.workspace }}/output @@ -150,15 +154,15 @@ jobs: - name: Upload PyInstaller Artifact uses: actions/upload-artifact@v4 with: - name: ${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip - path: ${{ github.workspace }}/output/${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip + name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ runner.arch }}.zip + path: ${{ github.workspace }}/output/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ runner.arch }}.zip - name: Upload Release Asset to GitHub Release uses: softprops/action-gh-release@v2 if: github.event_name == 'release' with: files: ${{ env.UPLOAD_PATH }} # Path to asset created in previous step - name: OpenTagViewer MacOS AirTag Exporter ${{ steps.extract_version.outputs.APP_VERSION }} + name: OpenTagViewer MacOS AirTag Exporter ${{ env.APP_VERSION }} env: GITHUB_TOKEN: ${{ secrets.MACOS_EXPORTER_APP_GITHUB_TOKEN }} # Provided by GitHub Actions @@ -173,30 +177,13 @@ jobs: && (github.event.release.prerelease == true || github.event.release.published_at != null) && startsWith(github.event.release.tag_name, 'macos-exporter-v') - steps: - - name: Extract Version from Tag - id: extract_version - run: | - # GITHUB_REF for tags is like 'refs/tags/macos-exporter-v1.0.3.1' - FULL_TAG_NAME="${{ github.ref }}" - - # Remove 'refs/tags/' prefix to get 'macos-exporter-v1.0.3.1' - TAG_WITHOUT_PREFIX="${FULL_TAG_NAME##refs/tags/}" - - # Find the index of '-v' - # This will be 'macos-exporter-' part, then we add 2 to skip '-v' - START_INDEX=$(( $(echo "$TAG_WITHOUT_PREFIX" | awk -F'-v' '{print length($1)}') + 2 )) - - # Extract everything from '-v' onwards - # Using cut or substring if available, or just awk - VERSION_STRING=$(echo "$TAG_WITHOUT_PREFIX" | cut -c "$START_INDEX"-) - - # Output the extracted version as a step output - echo "Extracted version: $VERSION_STRING" - - # !! this is reused below - echo "APP_VERSION=$VERSION_STRING" >> $GITHUB_OUTPUT + # The version is not parsed out of the tag here. It is whatever test-release-version + # confirmed the tag and python/main/wizard.py agree on, so the zip name, the release + # name, and the version the app reports about itself cannot come apart. + env: + APP_VERSION: ${{ needs.test-release-version.outputs.app-version }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -227,8 +214,8 @@ jobs: - name: Prepare and Upload MacOS App working-directory: ./python run: | - echo "Going to build zip with version: ${{ steps.extract_version.outputs.APP_VERSION }}" - APP_ZIP_NAME="${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip" + echo "Going to build zip with version: $APP_VERSION" + APP_ZIP_NAME="${{ env.APP_NAME }}-$APP_VERSION-${{ runner.arch }}.zip" cd ./dist zip -r $APP_ZIP_NAME OpenTagViewer.app/ OpenTagViewer mkdir -p ${{ github.workspace }}/output @@ -240,14 +227,14 @@ jobs: - name: Upload PyInstaller Artifact uses: actions/upload-artifact@v4 with: - name: ${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip - path: ${{ github.workspace }}/output/${{ env.APP_NAME }}-${{ steps.extract_version.outputs.APP_VERSION }}-${{ runner.arch }}.zip + name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ runner.arch }}.zip + path: ${{ github.workspace }}/output/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ runner.arch }}.zip - name: Upload Release Asset to GitHub Release uses: softprops/action-gh-release@v2 if: github.event_name == 'release' with: files: ${{ env.UPLOAD_PATH }} # Path to asset created in previous step - name: OpenTagViewer MacOS AirTag Exporter ${{ steps.extract_version.outputs.APP_VERSION }} + name: OpenTagViewer MacOS AirTag Exporter ${{ env.APP_VERSION }} env: GITHUB_TOKEN: ${{ secrets.MACOS_EXPORTER_APP_GITHUB_TOKEN }} # Provided by GitHub Actions \ No newline at end of file diff --git a/.github/workflows/macos-scripts-python.yml b/.github/workflows/macos-scripts-python.yml index 107a705e..36589d3e 100644 --- a/.github/workflows/macos-scripts-python.yml +++ b/.github/workflows/macos-scripts-python.yml @@ -24,7 +24,11 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + # 3.9 is what the Xcode Command Line Tools install, so it is the interpreter anyone + # running the wizard from source on a stock Mac actually gets. Dropping it means + # 3.10-only syntax can land and the wizard dies at import on a fresh machine, which + # is exactly how it broke once. + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout code @@ -54,3 +58,12 @@ jobs: working-directory: ./python run: | pytest ./test + + # The release check reads VERSION out of wizard.py by parsing the module, so a refactor + # that makes it unreadable - moving it into a class, computing it from parts - would + # otherwise only surface when a release failed. This is that same read, run whenever + # python/ changes. + - name: Check the exporter version is still readable + working-directory: . + run: | + python scripts/exporter_version.py --print diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml new file mode 100644 index 00000000..3449c69e --- /dev/null +++ b/.github/workflows/update-contributors.yml @@ -0,0 +1,52 @@ +name: Update contributors + +# Keeps app/src/main/assets/contributors.json and the bundled avatars current, so the +# Information page credits everyone without anyone having to remember to update a list. +# +# It runs here rather than during the app build on purpose: the output is committed source, +# and regenerating it on every build would dirty the working tree whenever someone changed +# their GitHub avatar, and would make offline builds fail. +# +# GITHUB_TOKEN gives 1000 requests/hour, so the 60/hour unauthenticated limit is irrelevant. + +on: + schedule: + # Weekly. Contributor lists do not move fast enough to warrant more. + - cron: '0 4 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Regenerate the contributor list + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/fetch_contributors.py + + - name: Open a PR if anything changed + uses: peter-evans/create-pull-request@v6 + with: + commit-message: Update the bundled contributor list + title: Update the bundled contributor list + body: | + Regenerated by `scripts/fetch_contributors.py`. + + Ordering is recency-weighted contribution volume, so it moves as people + come and go. Avatars are refreshed at the same time. + branch: chore/update-contributors + delete-branch: true + add-paths: | + app/src/main/assets/contributors.json + app/src/main/assets/contributors/ diff --git a/.gitignore b/.gitignore index 9987a8c3..6213752c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,8 @@ __pycache__/ app/release .coverage .pytest_cache -.venv \ No newline at end of file +.mypy_cache +.venv +tmp/ +# Local Claude Code settings +.claude/settings.local.json diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 8c34214c..0b7444f5 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -16,6 +16,19 @@ + + + + + + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b03b2c7e..fcd0d852 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,22 +4,16 @@ - - - - - + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b69f25ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,266 @@ +# AGENTS.md + +Rules for agents working on OpenTagViewer. Written for automated contributors, but the +constraints apply to anyone. + +## What this project is + +An Android app that shows Apple AirTag locations without an Apple device. Tags are exported +from a Mac once (see the wiki), imported as a zip, and located via Apple's Find My network +through [FindMy.py](https://github.com/malmeloo/FindMy.py). + +| Layer | Technology | +| --- | --- | +| App | Android, **Java** (not Kotlin), Gradle Kotlin DSL, `compileSdk`/`targetSdk` 35, `minSdk` 24 | +| Apple protocol | **Chaquopy** — CPython 3.12 embedded, bridged in `app/src/main/python/main.py` | +| Persistence | **Room** (`opentagviewer-db`) | +| Async | **RxJava3** through repositories and services | +| Boilerplate | **Lombok** (`@Builder`, `@Getter` on entities) | +| Desktop exporter | `python/` — tkinter wizard, PyInstaller | + +`app/src/main/python/` is packaged into the APK by Chaquopy. Nothing in it may import +Android or Java types — that is what makes it testable on plain CPython. + +--- + +## Rules + +### 1. Never ship a Room schema change without a migration + +Bumping `version` without a `Migration` throws at first database access **on upgrade only**. +A fresh install works fine, so it passes casual testing and then destroys every existing +user's imported beacons and location history. + +- Add a `Migration` and register it in `addMigrations(...)` +- **Never** use `fallbackToDestructiveMigration()` — recovery means redoing the macOS + export, and `allowBackup` is false so there is no backup +- Add a migration test, including the direct path from the oldest version (users skip + releases) +- Schemas are exported to `app/schemas/` and committed; include the new JSON in your change + +### 2. Do not claim something works if you have not run it + +Say plainly what was verified, what was assumed, and what could not be checked. If a change +needs a real Apple account, a real Mac, or hardware you do not have, state that rather than +implying coverage. A wrong "this works" is worse here than an honest gap, because the +failure mode is silent data loss on other people's devices. + +### 3. Verify library behaviour against the installed version + +FindMy.py's API changes significantly between minor versions. Methods and properties that +existed in 0.7.x are gone in 0.9.x. Before "fixing" code that looks wrong, check the pinned +version's actual source — several apparent bugs here are forced adaptations. + +```bash +python -m venv .venv && .venv/bin/pip install "FindMy==" +``` + +### 4. Respect the Anisette constraints + +- **Remote only.** `anisette` needs `unicorn`, a CPU emulator Chaquopy cannot build for + Android. `app/stubs/unicorn/` is a stand-in that makes the dependency tree resolve; every + method raises. Local Anisette does not work, and the stub is not a step toward it. +- **Sessions are bound to one server's machine identity.** Changing Anisette server requires + a re-login. That is inherent to how Apple binds the session, not a bug to work around — + rewriting the stored provider would leave the app running but silently failing auth. + +### 5. Never bundle an AMap API key + +AMap issues keys per developer account, bound to a package name and signing fingerprint, and +expects the key holder to be the app's operator. Users supply their own in Settings, applied +at runtime via `MapsInitializer.setApiKey`. + +Selecting AMap without a key must not save — both in Settings and on the first-run screen. + +### 6. Keep both key-alignment paths working + +Without a `KeyAlignmentRecord`, `FindMyAccessory` starts at index 0 from its pairing date, so +the first fetch searches the tag's entire history — ~50,000 keys for an 18-month-old tag, at +Apple's ~290-keys-per-request limit. That is an account-flagging risk, not just slowness. + +- Exports from format `0.0.2` onward carry the record; it is passed to + `FindMyAccessory.from_plist(plist, key_alignment_plist)` +- Older exports have none and fall back to the probe in `main.py` +- Changes must not break either path + +### 7. Add map providers behind `IMapProvider` + +Do not branch inside `MapsActivity`. The abstraction exists precisely so a new provider is a +new implementation — a third party added a MapLibre provider in ~80 lines because of it. + +### 8. Attribute other people's work + +When merging a contributor's changes, preserve authorship — cherry-pick, or set `--author`, +rather than copying file contents into your own commit. Credit them in the PR description. + +### 9. Bump the exporter's `VERSION` before tagging a release + +`VERSION` in `python/main/wizard.py` is the only place the macOS exporter's version is +written. It reaches the window title and, more importantly, every export it produces, as +`via: OpenTagViewer.app:` in `OPENTAGVIEWER.yml` — which is how anyone looking at a +zip later works out which exporter built it. + +Nothing patches it at build time, and nothing should: the wizard also runs from source (the +VM bootstrap, `python main/wizard.py`), and those exports stamp `via:` too, so a build-time +patch would make two artifacts from one commit disagree. + +So releasing is two steps, in this order: + +1. Commit the `VERSION` bump to `main` +2. Tag that commit `macos-exporter-v` and publish the release + +`scripts/exporter_version.py --tag ` enforces it, and runs in `test-release-version` +before either build job. A tag that disagrees fails the release rather than shipping a build +that lies about itself. Full procedure: [CONTRIBUTING.md](./CONTRIBUTING.md#releasing-the-macos-exporter). + +--- + +## Building and testing + +See **[CONTRIBUTING.md](./CONTRIBUTING.md)** for setup and every test suite. Short version: + +```bash +./gradlew testAll # everything that needs no device +./gradlew testAllOnDevice # the above plus instrumented tests +``` + +**Instrumented tests run on a Gradle managed device.** Nothing needs to be booted first — +Gradle provisions the emulator, runs the tests, and destroys it: + +```bash +./gradlew :app:testEmulatorDebugAndroidTest # 24 tests, well under two minutes +``` + +Use this rather than `connectedDebugAndroidTest`. The Android Gradle Plugin holds its ADB +connection inside the Gradle daemon and reuses it between invocations, so once a hand-started +emulator's adb daemon goes stale, the next run fails to install or hangs — with an error that +has nothing to do with the code. Recovering from that needs *both* an emulator restart and +`./gradlew --stop`, because restarting only the emulator leaves the daemon holding the dead +bridge. A managed device is created fresh per run, so nothing survives to go stale. It is +also the only form of this that CI can run unattended. + +Two consequences worth knowing: + +- The `aosp-atd` image carries no Play Services, so **a test that touches Maps will not run + on it** — that device would need a `google` image. Nothing today does. +- `./gradlew testDebugUnitTest` is close to meaningless: there is exactly one JVM test and it + asserts `2 + 2 == 4`. Everything real is instrumented. Do not report "tests pass" off it. + +`scripts/run_instrumented_tests.sh` remains as a fallback for running against an emulator you +already have open; it pins `ANDROID_SERIAL` so a run cannot install to a physical phone. + +Python must be on `PATH` — the build shells out to it to generate the unicorn stub wheel. + +**Type-check Python with pyright, not mypy.** The editors used here run Pylance, which is +pyright; default mypy is far more lenient and will pass code your editor flags. + +```bash +python -m pyright app/src/main/python/main.py +``` + +## Adding user-facing strings + +The app ships ten locales. A string missing from one of them silently falls back to English +for those users — the build succeeds, and it looks fine in whichever language you speak. Use +the helper rather than editing ten files: + +```bash +python scripts/add_strings.py # prints full usage and the input format +python scripts/add_strings.py --locales # which locales exist, discovered from the tree +python scripts/add_strings.py new.json # add strings to every locale +python scripts/add_strings.py --fill new.json # add only where missing, for back-filling +python scripts/add_strings.py --check # fail if any locale is missing a string +``` + +Translations are read **from a JSON file, never from a command-line argument**. That is not +a style preference: passing non-ASCII text through shell quoting has twice corrupted it here, +once putting a literal `\’` on screen where a French apostrophe belonged. + +The tool refuses to write unless every locale is supplied, escapes apostrophes, quotes and +ampersands for you, preserves the inline tags ``, `` and ``, and re-parses each file +afterwards so a malformed write fails immediately rather than at aapt time. Locales are +discovered from `app/src/main/res/values-*/strings.xml`, so adding a locale directory makes +it required with no change to the script. + +`--check` is worth running before opening a PR; it found eight strings missing across seven +locales the first time it was run. It also runs in CI, on every PR and every release. + +### Pre-commit hook + +`.githooks/pre-commit` runs the translation check, plus flake8 and pyright over whichever +Python files are staged, and refuses a commit that stages `secrets.properties`. Hooks are not +versioned, so each clone has to opt in once: + +```bash +git config core.hooksPath .githooks +``` + +It deliberately does not build or run tests. A hook that takes a minute gets bypassed with +`--no-verify` within a day, and a hook people routinely bypass is worse than none, because it +looks like a safety net that is not there. Use `--no-verify` when you genuinely need to. + +## Install the GitHub CLI + +If you are working with an agent, install and authenticate [`gh`](https://cli.github.com/): + +```bash +gh auth login +gh auth status +``` + +It is the difference between an agent that can only guess at a red build and one that can +read it. Without `gh`, a failing check is a coloured square on a web page the agent cannot +see; with it, the agent can find the failing step, read the log, fix the cause and say what +it was. The same applies to triaging issues before changing anything. + +```bash +gh pr checks # which checks passed, failed, are pending +gh run list --limit 5 # recent runs +gh run view --job --log-failed # the failing output, once the run has finished +gh issue list --state all --limit 100 # is this already reported? +gh issue view --comments # the discussion, which often has the diagnosis +``` + +**While a run is still in progress, `--log-failed` refuses**, reporting only that logs will +be available when the run completes — unhelpful when one job failed in seconds and another +has half an hour of emulator left. The step-level API answers immediately and tells you +exactly which step died: + +```bash +gh api repos///actions/jobs/ \ + --jq '.steps[] | "\(.conclusion // .status)\t\(.name)"' +``` + +Note that `gh`'s authentication is separate from git's. `gh` can be logged in while +`git push` still fails, if the remote is HTTPS with no cached credential or SSH with a +passphrase-protected key and no agent running. `gh` will still work for everything above. + +## Windows traps + +Each of these has already produced a hang that looked like something else: + +- **`python3` is usually a trap.** It resolves to a zero-byte Microsoft Store alias in + `%LOCALAPPDATA%\Microsoft\WindowsApps\` which hangs forever when run non-interactively + rather than failing. Prefer `python`; reject candidates whose file length is zero. +- **Git Bash rewrites POSIX paths.** `adb push ... /sdcard/Download/` becomes + `C:/Program Files/Git/sdcard/...`. `MSYS_NO_PATHCONV=1` fixes that but *also* ~~stops~~ + `JAVA_HOME` being translated, so `gradlew.bat` gets a POSIX path and dies. Never set it + globally. +- **adb appears to hang from Git Bash** on `start-server`, because MSYS waits on the + daemon's inherited handles. Use PowerShell, or have Android Studio open — it runs its own + adb server. +- **A gradle command that seems to hang is usually the environment, not the build.** Never + wrap one in a `grep` or a `timeout` that can swallow the error message — that turns a fast, + legible failure into an apparent hang. `JAVA_HOME` pointing below 17 is the usual culprit, + and it fails deep inside the Android Gradle Plugin with a message that reads like a plugin + bug. + +## Conventions + +- Java, not Kotlin, in `app/`. Match the surrounding style. +- Entities use Lombok `@Builder`; construct them that way. +- Comments explain *why*, especially where behaviour looks wrong but is forced by Apple's or + FindMy.py's API. +- User-facing strings go in `values/strings.xml` with translations in every supported locale. + **Use `scripts/add_strings.py` rather than editing the files by hand** — see below. +- Prefer fixing the root cause to adding a workaround. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..84fcb61a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,485 @@ +# Contributing + +Thanks for looking. This covers getting the project running, and how to test what you +change. For the rules a change has to satisfy — migrations, Anisette, API keys, attribution +— see **[AGENTS.md](./AGENTS.md)**, which applies to people as much as to automated +contributors. + +--- + +## Setting up + +### What you need + +| | Why | +| --- | --- | +| **JDK 17+** | The Android Gradle Plugin refuses to run on anything older, with an error that reads like a plugin bug. Android Studio's bundled runtime works: `export JAVA_HOME="/path/to/Android Studio/jbr"` | +| **Android SDK** | Either `local.properties` with `sdk.dir=...`, or `ANDROID_HOME` set | +| **Python 3** on `PATH` | The build shells out to it to generate the stub `unicorn` wheel (`scripts/build_unicorn_stub_wheel.py`). If your interpreter has an unusual name, pass `-PpythonExecutable=...` | +| **Android Studio** (optional) | Everything works from the CLI, but the IDE is easier for running the app on a device | +| **[`gh`](https://cli.github.com/)** (optional) | Strongly recommended if you work with a coding agent — see below | + +Easiest route to the first three is Android Studio, which ships a JDK and can install the +SDK for you. Otherwise: [Temurin 17](https://adoptium.net/temurin/releases/?version=17), +the [command line tools](https://developer.android.com/studio#command-tools), and +[python.org](https://www.python.org/downloads/). + +### Clone and configure + +```bash +git clone https://github.com/parawanderer/OpenTagViewer.git +cd OpenTagViewer +``` + +**A Google Maps API key.** The app will build without one, but the map will not load. +Create `secrets.properties` in the repository root: + +```properties +MAPS_API_KEY=your_key_here +``` + +Get one from the [Google Maps Platform console](https://console.cloud.google.com/google/maps-apis/), +enabling **Maps SDK for Android**. `secrets.properties` is gitignored and must stay that +way — the pre-commit hook refuses a commit that stages it. `local.defaults.properties` +supplies a placeholder so a fresh clone still compiles. + +If you would rather not deal with a key at all, AMap works without one at build time — users +supply their own in Settings — though it only covers mainland China. + +### Install the git hook + +Hooks are not versioned, so this is once per clone: + +```bash +git config core.hooksPath .githooks +``` + +`.githooks/pre-commit` runs the translation check, plus flake8 and pyright over whatever +Python you staged, and refuses to commit `secrets.properties`. It deliberately does not +build or run tests — a hook that takes a minute gets bypassed with `--no-verify` within a +day, and a routinely-bypassed hook is worse than none. Use `--no-verify` when you genuinely +need to. + +Optional but recommended for Python work: + +```bash +python -m pip install flake8 pyright +``` + +### If you use a coding agent, install `gh` + +```bash +gh auth login +``` + +Worth doing before anything else. Without it, a red check on your PR is a coloured square on +a web page your agent cannot see, so it can only guess. With it, the agent reads the failing +step and its log, fixes the cause, and tells you what it was. + +`gh` also lets an agent check whether something is already reported before changing anything. AGENTS.md has the specific +commands, including how to read a failure while the rest of the run is still going. + +Note that `gh` authenticates separately from git: `gh` can be logged in while `git push` +still fails, if your remote is HTTPS with no cached credential, or SSH with a +passphrase-protected key and no agent running. + +### Build it + +```bash +export JAVA_HOME="/path/to/jbr" +./gradlew assembleDebug # ./gradlew.bat on Windows +``` + +Debug builds install as `dev.wander.android.opentagviewer.debug`, labelled +"OpenTagViewer (debug)" with an inverted icon, so they sit alongside a real install rather +than replacing it. **Never uninstall a production install to force an install** — +`allowBackup` is false, so the beacons and location history are gone for good, and getting +them back means redoing the macOS export. + +--- + +## Testing + +Tests live in five places, because the code runs in three environments: the JVM, an Android +runtime, and CPython (both inside the app via Chaquopy, and on the desktop for the export +wizard). + +| Suite | Location | Runner | Needs a device? | +| --- | --- | --- | --- | +| Android unit tests | `app/src/test/java/` | Gradle / JUnit | no | +| Android instrumented tests | `app/src/androidTest/java/` | Gradle / JUnit + emulator | provisioned for you | +| Chaquopy bridge tests | `app/src/test/python/` | pytest | no | +| Desktop wizard tests | `python/test/` | pytest | no | +| String tooling tests | `scripts/test/` | pytest | no | + +### Run everything + +```bash +export JAVA_HOME="/path/to/jbr" + +./gradlew testAll # everything that needs no device +./gradlew testAllOnDevice # the above, plus instrumented tests +``` + +`testAll` is deliberately separate so you don't get a failure just for not having an +emulator. It says the instrumented tests were skipped. + +Both create a virtualenv under `app/build/test-venv` on first run and install the Python +dependencies into it, so a fresh clone works with no manual setup. The first run is slow; +afterwards it is reused. If your Python is not discovered automatically: + +```bash +./gradlew testAll -PpythonExecutable=/path/to/python +``` + +> On Windows, `python3.exe` in `%LOCALAPPDATA%\Microsoft\WindowsApps\` is a zero-byte +> Microsoft Store alias that **hangs** rather than failing when run non-interactively. The +> build skips those deliberately. If any Python-invoking tooling hangs mysteriously on +> Windows, that alias is a good first suspect. + +### Android instrumented tests + +Gradle provisions the emulator, runs the tests and tears it down — nothing needs to be +booted first: + +```bash +./gradlew :app:testEmulatorDebugAndroidTest +``` + +Use this rather than `connectedDebugAndroidTest`. The Android Gradle Plugin holds its ADB +connection inside the Gradle daemon and reuses it between invocations, so once a +hand-started emulator's adb daemon goes stale, the next run fails to install or hangs with +an error that has nothing to do with your code. Recovering needs *both* an emulator restart +and `./gradlew --stop`. A managed device is created fresh per run, so nothing survives to go +stale. + +The device is defined in `testOptions { managedDevices { ... } }` in `app/build.gradle.kts` +and uses an `aosp-atd` image, which has **no Play Services** — a test that needs Maps would +need a `google` image. + +To run against a device you already have, `scripts/run_instrumented_tests.sh` pins +`ANDROID_SERIAL` so a run cannot install to a physical phone, and restarts the emulator and +the Gradle daemon if a run wedges. + +Report: `app/build/reports/androidTests/managedDevice/debug/allDevices/index.html` + +Targeting a subset: + +```bash +./gradlew :app:testEmulatorDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.package=dev.wander.android.opentagviewer.db +``` + +### Android unit tests + +Plain JVM, no Android framework. Be aware there is currently almost nothing here — the +meaningful coverage is instrumented, so a green `test` run says very little. + +```bash +./gradlew testDebugUnitTest +``` + +Report: `app/build/reports/tests/testDebugUnitTest/index.html` + +### Chaquopy bridge tests + +Cover `app/src/main/python/main.py`, the module Chaquopy packages into the APK. It imports +no Android or Java types, so it runs on plain CPython. + +```bash +python -m venv .venv +.venv/bin/pip install -r app/src/test/python/requirements.txt # Windows: .venv/Scripts/pip + +python -m pytest app/src/test/python -v +python -m pytest app/src/test/python -k alignment +``` + +The pins in `app/src/test/python/requirements.txt` must match the +`chaquopy { pip { ... } }` block in `app/build.gradle.kts`, or the tests exercise a +different FindMy version than the app ships. `test_pinned_versions_match_the_app_build` +enforces that. + +Fixtures live in `app/src/test/resources/` — a redacted copy of a real export. See the +README there for what was redacted and why. + +### Desktop wizard tests + +For the macOS export wizard under `python/`. + +```bash +cd python +python -m pip install -r requirements.txt flake8 pytest + +python -m pytest ./test +flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +``` + +### String tooling tests + +```bash +python -m pytest scripts/test -v +``` + +`scripts/add_strings.py` gates CI's translation check, so a bug in it would report green +while protecting nothing. + +### Which Python each tree targets + +There are three, and they are not the same: + +| Tree | Version | Why | +| --- | --- | --- | +| `python/` (export wizard) | **3.9+** | 3.9 is what the Xcode Command Line Tools install, so it is what someone running the wizard from source on a stock Mac gets. Tested across 3.9–3.13 in CI | +| `app/src/main/python/` | **3.12** | Pinned by the `chaquopy { }` block; it only ever runs inside the app | +| `scripts/` | **3.12** | Developer tooling, run on a dev machine. Not tested below 3.12 — do not assume it works on 3.9 | + +Only `python/` has a floor worth respecting, and it is a real constraint rather than a +preference: a module-level `tuple[int, int] | None` annotation is evaluated at import, so +3.10-only syntax there means the wizard dies before it starts on a stock Mac. Hence +`from __future__ import annotations` in those files, and 3.9 in the CI matrix. + +### Type checking + +**pyright, not mypy.** The editors used here run Pylance, which is pyright; mypy's defaults +are lenient enough to pass code Pylance flags. + +```bash +python -m pip install pyright pillow -r app/src/test/python/requirements.txt +python -m pyright app/src/main/python scripts +``` + +The installs matter: pyright reports an unresolved import as an error, so without `findmy`, +`NSKeyedUnArchiver` and `pillow` present it fails while telling you nothing about your code. + +`python/` is not clean yet and is excluded from CI's pyright step. + +--- + +## Adding user-facing strings + +For manual changes made by a human, you can make changes in the Android Studio UI (easiest) +or make changes in the `strings.xml` file yourself. + +For agent based changes: never edit the ten `strings.xml` files by terminal, and never pipe +translations through a shell loop — that has corrupted non-ASCII text here twice. +See the section in [AGENTS.md](./AGENTS.md#adding-user-facing-strings). + +```bash +python scripts/add_strings.py # usage and input format +python scripts/add_strings.py new.json # add to every locale +python scripts/add_strings.py --check # fail if any locale is missing one +``` + +--- + +## Running the export wizard on a Mac + +Testing an export means a real Mac signed into iCloud, usually a VM. A fresh macOS install +has **neither git nor python3** — both arrive with the Xcode Command Line Tools — so +`scripts/bootstrap_macos.sh` does the lot: installs the tools, clones, creates a virtualenv, +installs the dependencies and launches the wizard. It is safe to re-run. + +It cannot live in the clone you do not have yet, so fetch it with `curl`, which macOS does +ship: + +```bash +curl -fsSLO https://raw.githubusercontent.com/parawanderer/OpenTagViewer/main/scripts/bootstrap_macos.sh +less bootstrap_macos.sh # read it before running it +bash bootstrap_macos.sh # or: bash bootstrap_macos.sh some-branch +``` + +Read it first rather than piping `curl` straight into `bash`. It is a hundred lines, and the +people using this app are exactly the people who should not run unread code off the internet +as a habit. + +
+What it does, if you would rather do it by hand + +```bash +# 1. Command Line Tools. Opens a GUI installer and returns immediately, so wait for it. +# Tested with --version rather than `command -v`: a bare macOS ships stubs at +# /usr/bin/git and /usr/bin/python3 that exist only to trigger this installer, so +# "is it on PATH" answers yes long before either one can actually run. +until git --version >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; do + xcode-select --install 2>/dev/null + echo "Click Install in the dialog that opened, then leave this running..." + sleep 20 +done + +# 2. The code. Swap the branch for whatever you are testing. +git clone -b main --depth 1 https://github.com/parawanderer/OpenTagViewer.git +cd OpenTagViewer/python + +# 3. Dependencies, isolated from the system Python. +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# 4. Run it. +PYTHONPATH=. python3 main/wizard.py +``` + +
+ +`PYTHONPATH=.` is not optional: `wizard.py` does `from main.airtag_decryptor import ...`, and +running the file directly puts `python/main` on `sys.path` rather than `python/`, so the +import fails. It is the same thing the CI workflow sets. + +Then check the export actually contains what it should: + +```bash +unzip -l ~/Desktop/OpenTagViewer_export_*.zip | grep -iE "OPENTAGVIEWER.yml|KeyAlignmentRecords" +``` + +You want `version: 0.0.2` in the yml and `KeyAlignmentRecords//.plist` entries. +Without those the app has no key alignment and every tag's first fetch searches its entire +history — see rule 6 in [AGENTS.md](./AGENTS.md). + +### If the GUI will not start + +The wizard is tkinter, and Tk can fail to initialise even when `import tkinter` works — the +process then dies with `Abort trap: 6` and no traceback. A Docker-OSX VM will do this if its +`SystemVersion.plist` disagrees with the version the OS reports through the API Tk queries, +so Tk concludes the OS is too old for itself. + +**The export does not need the GUI.** `airtag_decryptor.py` has its own CLI and imports no +Tk, and it owns `WHITELISTED_DIRS` — the part that actually decides what gets exported: + +```bash +cd ~/OpenTagViewer/python && . .venv/bin/activate +PYTHONPATH=. python3 main/airtag_decryptor.py -o ~/Desktop/otv_decrypted --rename-legacy +``` + +That writes decrypted `OwnedBeacons/`, `BeaconNamingRecord/` and `KeyAlignmentRecords/` +folders, prompting for your login password through the system keychain dialog. What it does +not do is package them, since that is wizard code. To build a zip the app can import: + +```bash +cd ~/Desktop/otv_decrypted + +cat > OPENTAGVIEWER.yml < --hours-back 24 + +# Generate a synthetic beacon plist of a given age. +python scripts/make_test_beacon_plist.py out.plist --days-old 730 +``` + +--- + +## What CI runs + +| Workflow | When | What | +| --- | --- | --- | +| `build-debug.yml` | push/PR to `main` | Translation check, pyright, string tooling tests, instrumented tests on an emulator, JVM tests, Chaquopy bridge tests, debug APK | +| `build-release.yml` | on release | Translation check, JVM tests, Chaquopy bridge tests, release APK | +| `macos-scripts-python.yml` | `python/**` changes | Wizard tests across Python 3.10–3.13 on macOS 14 | +| `macos-exporter-python.yml` | on release | Tag/version check, wizard tests, the PyInstaller build for both architectures | +| `update-contributors.yml` | weekly | Regenerates the contributor list on the Information page, opens a PR if it changed | + +The instrumented job needs KVM on the runner; the workflow enables it before starting the +emulator. + +--- + +## Releasing the macOS exporter + +The exporter's version lives in exactly one place — `VERSION` in `python/main/wizard.py`: + +```python +VERSION = "1.0.5" +``` + +It shows in the window title, and it is stamped into every export as +`via: OpenTagViewer.app:1.0.5` inside `OPENTAGVIEWER.yml`. That field is how a maintainer +reading a bug report works out which exporter produced the zip in front of them, so it has to +be true. + +**Nothing rewrites it at build time.** The release tag names the zip and the GitHub release; +the app keeps whatever was committed. That is deliberate rather than an oversight — the wizard +also runs straight from source (the VM bootstrap below, `python main/wizard.py`), and those +runs stamp `via:` as well. If CI patched the tag into the source, a binary and a from-source +export built from the same commit would claim different versions, which is the same drift in a +place nobody would think to look. + +So a release is two steps, in this order: + +```bash +# 1. Bump it, commit it, push it +# (edit python/main/wizard.py -> VERSION = "1.0.6") +git commit -am "Bump the macOS exporter to 1.0.6" +git push origin main + +# 2. Tag that commit and publish the release +gh release create macos-exporter-v1.0.6 --title "OpenTagViewer MacOS AirTag Exporter 1.0.6" +``` + +The tag must be `macos-exporter-v` followed by exactly what `VERSION` says. Before either +build job starts, `test-release-version` runs: + +```bash +python scripts/exporter_version.py --tag macos-exporter-v1.0.6 +``` + +which fails the release, with instructions, if the two disagree — so the mistake costs a +minute rather than an incorrectly labelled build on the releases page. Both build jobs then +take the version from that job's output instead of parsing the tag themselves, so the zip +name, the release title, and the version the app reports cannot come apart. + +You can run the same check locally before tagging: + +```bash +python scripts/exporter_version.py --print # what the source declares +python scripts/exporter_version.py --tag macos-exporter-v1.0.6 # would this tag be accepted? +``` + +If you tagged before bumping, the fix is to push the bump, delete the release and its tag, and +re-tag the new commit. Releasing the Android app is unrelated and unaffected — its version +lives in `app/build.gradle.kts`. + +## Opening a pull request + +- Run `./gradlew testAll` and `python scripts/add_strings.py --check` first +- If you changed the database schema, include the migration, a migration test, and the + exported schema JSON — see rule 1 in [AGENTS.md](./AGENTS.md) +- Say what you verified and what you could not. A change needing a real Apple account, a + Mac, or hardware you do not have is fine; claiming coverage you do not have is not diff --git a/README.md b/README.md index f14aae9b..f2495f29 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ OpenTagViewer +[![Android build & tests](https://github.com/parawanderer/OpenTagViewer/actions/workflows/build-debug.yml/badge.svg?branch=main)](https://github.com/parawanderer/OpenTagViewer/actions/workflows/build-debug.yml) +[![macOS export wizard](https://github.com/parawanderer/OpenTagViewer/actions/workflows/macos-scripts-python.yml/badge.svg?branch=main)](https://github.com/parawanderer/OpenTagViewer/actions/workflows/macos-scripts-python.yml) +[![Release](https://github.com/parawanderer/OpenTagViewer/actions/workflows/build-release.yml/badge.svg)](https://github.com/parawanderer/OpenTagViewer/actions/workflows/build-release.yml) + Apparently, this is the first ** Android App** to allow you to view/track your ** official Apple AirTags**. I made this because I couldn't find any app or webpage that lets me do this @@ -14,7 +18,7 @@ This project is a relatively polished looking Android/Java UI-wrapper around the
-> [!WARNING] +> [!WARNING] > This project is not afilliated with Apple Inc. or Android/Google LLC in any capacity @@ -57,8 +61,20 @@ See [📖 wiki](https://github.com/parawanderer/OpenTagViewer/wiki) for more det Contributions/MRs are more than welcome. -Plenty of things can be improved in this project as it was mostly a "hackathony" thing I tried to throw together ASAP and make presentable for layusers. -Plenty of best practices haven't been followed, e.g. in the Testing & CI/CD setup. +This started as a "hackathony" thing thrown together ASAP and made presentable for layusers, +and plenty can still be improved. + +There are tests: Android unit and instrumented tests, tests for the Python bridge that talks +to Apple, and tests for the macOS export wizard. CI runs all of them, including the +instrumented tests on an emulator it provisions itself. New useful test contributions are welcome! + +📋 **[CONTRIBUTING.md](./CONTRIBUTING.md)** covers getting set up — the JDK, the SDK, the Maps +API key, the git hook — and how to run every test suite: the Android unit and instrumented +tests, the Chaquopy bridge tests, and the desktop wizard tests, plus what CI runs and a few +offline diagnostic scripts that need no Apple account. + +📐 **[AGENTS.md](./AGENTS.md)** is the rules a change has to satisfy — migrations, Anisette, +API keys, attribution. Written for automated contributors, but it applies to people too. **I think it would be nice if the app could support the following features:** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd5f58b7..4fbd6215 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,15 @@ plugins { secrets { // To add your Maps API key to this project: // 1. If the secrets.properties file does not exist, create it in the same folder as the local.properties file. - // 2. Add this line, where YOUR_API_KEY is your API key: + // 2. Add these lines, where YOUR_API_KEY is your API key: // MAPS_API_KEY=YOUR_API_KEY + // + // How to get Google Maps API Key: + // - Visit: https://console.cloud.google.com/google/maps-apis/ + // + // How to get AMap API Key (高德地图 API Key): + // - Visit: https://console.amap.com/dev/key/app + // - Guide: https://lbs.amap.com/api/android-sdk/guide/create-project/get-key propertiesFileName = "secrets.properties" // A properties file containing default secret values. This file can be @@ -33,6 +40,23 @@ android { ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + + // Export the Room schema as JSON on every build. These are committed (see + // app/schemas/) so schema changes show up as a reviewable diff, and so + // MigrationTestHelper can build an old database to migrate from. + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") + } + } + } + + // Makes the exported schemas readable by instrumented tests at runtime, which is + // how MigrationTestHelper creates a v1 database to run MIGRATION_1_2 against. + sourceSets { + getByName("androidTest") { + assets.srcDirs(files("$projectDir/schemas")) + } } signingConfigs { @@ -46,6 +70,8 @@ android { buildTypes { release { + // Keeps the launcher name as-is for real installs. + manifestPlaceholders["appLabel"] = "@string/app_name" signingConfig = signingConfigs.getByName("release") isDebuggable = false isMinifyEnabled = false @@ -56,6 +82,23 @@ android { } debug { isDebuggable = true + + // Install debug builds alongside a release install rather than colliding with + // it. Without this, both share the applicationId but are signed with different + // keys, so installing a debug build over a real one fails with + // INSTALL_FAILED_UPDATE_INCOMPATIBLE and the only way forward is to uninstall - + // which permanently destroys the user's imported beacons and location history. + // allowBackup is false, so there is no backup to restore from either. + // + // Note: a Maps API key restricted to the release package name will not authorise + // this one. Add "dev.wander.android.opentagviewer.debug" (with the debug keystore + // SHA-1) to the key's restrictions if you need maps to render in debug builds. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + + // Distinct launcher name, otherwise a debug install sits next to a real one + // with an identical icon and label and there is no way to tell them apart. + manifestPlaceholders["appLabel"] = "OpenTagViewer (debug)" } } compileOptions { @@ -71,18 +114,128 @@ android { androidResources { // generateLocaleConfig = true } + + testOptions { + managedDevices { + localDevices { + // Gradle provisions, boots, and tears down this emulator itself. + // + // Running against an emulator you started by hand is unreliable: the Android + // Gradle Plugin holds its ADB connection inside the Gradle daemon and reuses + // it between invocations, so once the emulator's adb daemon goes stale the + // next run fails to install or hangs, with an error that has nothing to do + // with the code. A managed device is created fresh per run, so there is no + // connection left over to go stale. It is also what CI can run unattended. + // + // aosp-atd is a stripped-down image built for tests: no Play Services, and no + // Maps as a result. Fine here, because none of the instrumented tests start + // MapsActivity - they cover the database migrations, the repositories and the + // keystore. Anything that needs Maps has to move to a "google" image. + create("testEmulator") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + } + } } lombok { version = libs.versions.lombokVersion.get() } +// FindMy >= 0.9 depends on anisette, which depends on unicorn (a CPU emulator used only +// for *local* Anisette). Chaquopy cannot build unicorn's native code for Android, so the +// whole dependency tree fails to resolve without a stand-in. We build a pure-Python stub +// wheel from the real sources in app/stubs/unicorn/ rather than checking in a prebuilt +// .whl - a binary artifact impersonating a well-known dependency is hard to audit. +val unicornStubWheel = layout.buildDirectory.file( + "generated/stub-wheels/unicorn-2.1.1-py3-none-any.whl" +) + +/** + * Locate a usable Python 3 interpreter. + * + * On Windows, `python3` is usually a zero-byte Microsoft Store "App Execution Alias" that + * hangs indefinitely when run non-interactively instead of failing, so candidates are + * ordered per-platform and stub aliases are filtered out by inspecting the file rather + * than by executing it. + */ +fun resolvePythonExecutable(): String { + providers.gradleProperty("pythonExecutable").orNull?.let { return it } + + val isWindows = System.getProperty("os.name").startsWith("Windows", ignoreCase = true) + val names = if (isWindows) listOf("python.exe", "python3.exe") else listOf("python3", "python") + + val pathDirs = (System.getenv("PATH") ?: "").split(File.pathSeparator).filter { it.isNotBlank() } + for (name in names) { + for (dir in pathDirs) { + val candidate = File(dir, name) + if (!candidate.isFile || !candidate.canExecute()) continue + // Store aliases are zero-length reparse points; executing one blocks forever. + if (candidate.length() == 0L) continue + if (candidate.absolutePath.contains("WindowsApps", ignoreCase = true)) continue + return candidate.absolutePath + } + } + + throw GradleException( + "No usable Python 3 interpreter found on PATH (looked for ${names.joinToString(", ")}). " + + "Chaquopy needs one to build the unicorn stub wheel. " + + "Install Python 3 or pass -PpythonExecutable=/path/to/python." + ) +} + +// A real task rather than configuration-time work: this used to run on every Gradle +// invocation, including every IDE sync, which blocked them. +val generateUnicornStubWheel by tasks.registering(Exec::class) { + group = "build" + description = "Builds the pure-Python unicorn stub wheel that lets FindMy 0.9.x resolve." + + val script = rootProject.file("scripts/build_unicorn_stub_wheel.py") + + inputs.dir(layout.projectDirectory.dir("stubs/unicorn")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(script).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.file(unicornStubWheel) + outputs.cacheIf { true } + + commandLine(resolvePythonExecutable(), script.absolutePath, unicornStubWheel.get().asFile.absolutePath) +} + +// Chaquopy installs from the wheel path, so it must exist before pip runs. +tasks.matching { it.name.contains("PythonRequirements") || it.name.contains("PythonReqs") } + .configureEach { dependsOn(generateUnicornStubWheel) } + +// Deliberately NOT wired into assembleDebug/assembleRelease. +// +// Its output is committed source, so running it on every build would either dirty the working +// tree whenever somebody pushes a commit or changes their GitHub avatar, or make an offline +// build fail. Refreshing it is a repository event, not a build event: the scheduled workflow +// in .github/workflows/update-contributors.yml runs this and opens a PR when the list moves. +// Run it by hand any time with ./gradlew updateContributors. +val updateContributors by tasks.registering(Exec::class) { + group = "build" + description = "Regenerates the contributor list and avatars bundled into the Information page." + + val script = rootProject.file("scripts/fetch_contributors.py") + + inputs.file(script).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.file(layout.projectDirectory.file("src/main/assets/contributors.json")) + outputs.dir(layout.projectDirectory.dir("src/main/assets/contributors")) + // Talks to the network, so its result is not reproducible from its inputs. + outputs.upToDateWhen { false } + + commandLine(resolvePythonExecutable(), script.absolutePath) +} + chaquopy { defaultConfig { version = "3.12" pip { // SEE: https://chaquo.com/chaquopy/doc/current/android.html#android-requirements - install("FindMy==0.7.6") + install(unicornStubWheel.get().asFile.absolutePath) + install("FindMy==0.9.8") install("NSKeyedUnArchiver==1.5") } } @@ -128,12 +281,18 @@ dependencies { implementation(libs.androidx.emoji.views.helper) implementation(libs.androidx.emoji.picker) + // 高德地图SDK - Android 3D地图 V9.8.3 + // 参考文档:https://lbs.amap.com/api/android-sdk/gettingstarted + // 注意:3D地图SDK已包含定位功能,无需单独引入location SDK + implementation(libs.amap.map3d) + testImplementation(libs.junit) testImplementation(libs.android.room.testing) testCompileOnly(libs.projectlombok) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.android.room.testing) annotationProcessor(libs.projectlombok) annotationProcessor(libs.android.room.compiler) @@ -141,4 +300,4 @@ dependencies { testAnnotationProcessor(libs.projectlombok) coreLibraryDesugaring(libs.desugar.jdk.libs) -} \ No newline at end of file +} diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json new file mode 100644 index 00000000..bc227b59 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/1.json @@ -0,0 +1,405 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "4059c5ff95bec440e4a41a8b6f45e8c3", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4059c5ff95bec440e4a41a8b6f45e8c3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json new file mode 100644 index 00000000..e8b48415 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/2.json @@ -0,0 +1,411 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "bc82a62bd44adcb4abfdfc6d038db3cb", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `accessory_json` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc82a62bd44adcb4abfdfc6d038db3cb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json new file mode 100644 index 00000000..cee25fc9 --- /dev/null +++ b/app/schemas/dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase/3.json @@ -0,0 +1,417 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "66f08a6fd861873f88fb7e1ae84b4070", + "entities": [ + { + "tableName": "Import", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `version` TEXT, `imported_at` INTEGER NOT NULL, `exported_at` INTEGER NOT NULL, `source_user` TEXT, `via` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "importedAt", + "columnName": "imported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exportedAt", + "columnName": "exported_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceUser", + "columnName": "source_user", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exportedVia", + "columnName": "via", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BeaconNamingRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `version` TEXT, `content` TEXT, `is_removed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BeaconNamingRecord_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BeaconNamingRecord_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "OwnedBeacons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `import_id` INTEGER, `content` TEXT, `version` TEXT, `is_removed` INTEGER NOT NULL, `accessory_json` TEXT, `alignment_plist` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`import_id`) REFERENCES `Import`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isRemoved", + "columnName": "is_removed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessoryJson", + "columnName": "accessory_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alignmentPlist", + "columnName": "alignment_plist", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_OwnedBeacons_import_id", + "unique": false, + "columnNames": [ + "import_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_OwnedBeacons_import_id` ON `${TABLE_NAME}` (`import_id`)" + } + ], + "foreignKeys": [ + { + "table": "Import", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "import_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LocationReport", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hash_id` TEXT NOT NULL, `beacon_id` TEXT NOT NULL, `published_at` INTEGER NOT NULL, `description` TEXT, `timestamp` INTEGER NOT NULL, `confidence` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `horizontal_accuracy` INTEGER NOT NULL, `status` INTEGER NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`hash_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "hashId", + "columnName": "hash_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "published_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confidence", + "columnName": "confidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "horizontalAccuracy", + "columnName": "horizontal_accuracy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hash_id" + ] + }, + "indices": [ + { + "name": "index_LocationReport_hash_id_beacon_id_timestamp", + "unique": false, + "columnNames": [ + "hash_id", + "beacon_id", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LocationReport_hash_id_beacon_id_timestamp` ON `${TABLE_NAME}` (`hash_id`, `beacon_id`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DailyHistoryFetchRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_time` INTEGER NOT NULL, `beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, PRIMARY KEY(`day_start_time`, `beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartTime", + "columnName": "day_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_time", + "beacon_id" + ] + }, + "indices": [ + { + "name": "index_DailyHistoryFetchRecord_beacon_id", + "unique": false, + "columnNames": [ + "beacon_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DailyHistoryFetchRecord_beacon_id` ON `${TABLE_NAME}` (`beacon_id`)" + } + ], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UserBeaconOptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`beacon_id` TEXT NOT NULL, `last_update` INTEGER NOT NULL, `ui_name` TEXT, `ui_emoji` TEXT, PRIMARY KEY(`beacon_id`), FOREIGN KEY(`beacon_id`) REFERENCES `OwnedBeacons`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "beaconId", + "columnName": "beacon_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiName", + "columnName": "ui_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiEmoji", + "columnName": "ui_emoji", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "beacon_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "OwnedBeacons", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "beacon_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '66f08a6fd861873f88fb7e1ae84b4070')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java index 6be58d1c..223bfbea 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ExampleInstrumentedTest.java @@ -24,6 +24,9 @@ public class ExampleInstrumentedTest { public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("dev.wander.android.opentagviewer", appContext.getPackageName()); + // Debug builds carry an applicationIdSuffix so they can install alongside a real + // one, so the package name is only the base id in a release build. + assertEquals(BuildConfig.APPLICATION_ID, appContext.getPackageName()); + assertTrue(appContext.getPackageName().startsWith("dev.wander.android.opentagviewer")); } } \ No newline at end of file diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java new file mode 100644 index 00000000..fb54172e --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/repo/BeaconRepositoryBackfillTest.java @@ -0,0 +1,280 @@ +package dev.wander.android.opentagviewer.db.repo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.room.Room; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import dev.wander.android.opentagviewer.db.room.OpenTagViewerDatabase; +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter; + +/** + * Covers the lazy {@code accessory_json} backfill. + *
+ * Beacons imported under FindMy 0.7.6 have no accessory JSON, and the v1 → v2 migration + * deliberately leaves the column NULL. Without a working backfill those beacons are + * excluded from every future fetch - they simply stop updating, with nothing but a + * logcat warning to show for it. That is a silent failure on the user's existing data, + * so it needs covering directly. + */ +@RunWith(AndroidJUnit4.class) +public class BeaconRepositoryBackfillTest { + + private static final String PLIST = ""; + private static final String CONVERTED_JSON = "{\"type\":\"accessory\",\"converted\":true}"; + + private OpenTagViewerDatabase db; + + /** Records what it was asked to convert so tests can assert on call count and input. */ + private static class RecordingConverter implements PlistToAccessoryJsonConverter { + final List calls = new ArrayList<>(); + final List alignmentCalls = new ArrayList<>(); + private final String result; + + RecordingConverter(String result) { + this.result = result; + } + + @Override + public String convert(String plistXml, String alignmentPlistXml) { + this.calls.add(plistXml); + this.alignmentCalls.add(alignmentPlistXml); + return this.result; + } + } + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + this.db = Room.inMemoryDatabaseBuilder(context, OpenTagViewerDatabase.class) + .allowMainThreadQueries() + .build(); + } + + @After + public void tearDown() { + this.db.close(); + } + + private void insertBeacon(String beaconId, String content, String accessoryJson) { + insertBeacon(beaconId, content, accessoryJson, null); + } + + private void insertBeacon(String beaconId, String content, String accessoryJson, String alignmentPlist) { + this.db.ownedBeaconDao().insertAll(OwnedBeacon.builder() + .id(beaconId) + .importId(null) + .content(content) + .version("1.0") + .isRemoved(false) + .accessoryJson(accessoryJson) + .alignmentPlist(alignmentPlist) + .build()); + } + + /** + * The alignment record is what stops the first fetch searching the tag's whole + * history, so the backfill has to hand it to the converter rather than dropping it. + */ + @Test + public void backfillPassesTheStoredAlignmentRecordToTheConverter() { + insertBeacon("beacon-a", PLIST, null, "ALIGNMENT-PLIST"); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, converter.alignmentCalls.size()); + assertEquals("ALIGNMENT-PLIST", converter.alignmentCalls.get(0)); + } + + /** Exports predating format 0.0.2 have no alignment record; that must still convert. */ + @Test + public void backfillWorksWithoutAnAlignmentRecord() { + insertBeacon("beacon-a", PLIST, null, null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + var requests = repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertNull("no alignment record should be passed as null", converter.alignmentCalls.get(0)); + } + + /** The migrated case: NULL accessory_json is converted from the retained plist and persisted. */ + @Test + public void backfillsAndPersistsWhenAccessoryJsonIsNull() { + insertBeacon("beacon-a", PLIST, null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertEquals("beacon-a", requests.get(0).getBeaconId()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + + assertEquals("converter should have been called exactly once", 1, converter.calls.size()); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("backfill must be persisted, not just returned", CONVERTED_JSON, stored.accessoryJson); + } + + /** Once backfilled, later fetches must not pay for the Chaquopy round trip again. */ + @Test + public void doesNotReconvertWhenAccessoryJsonAlreadyPresent() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var converter = new RecordingConverter("SHOULD-NOT-BE-USED"); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertEquals(1, requests.size()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + assertTrue("converter must not be called when the column is populated", converter.calls.isEmpty()); + } + + /** + * Conversion failing is not permanent - Python may simply not be up yet - so the row + * must be left NULL and retried next time, never poisoned with a bad value. + */ + @Test + public void skipsBeaconAndLeavesColumnNullWhenConversionFails() { + insertBeacon("beacon-a", PLIST, null); + var converter = new RecordingConverter(null); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("beacon-a", PLIST)).blockingFirst(); + + assertTrue("unconvertible beacon must be dropped from the batch", requests.isEmpty()); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertNull("a failed conversion must remain retryable", stored.accessoryJson); + } + + /** One bad beacon must not take down the fetch for the others. */ + @Test + public void oneFailedConversionDoesNotDropTheOthers() { + insertBeacon("good-1", PLIST, CONVERTED_JSON); + insertBeacon("bad", PLIST, null); + insertBeacon("good-2", PLIST, CONVERTED_JSON); + + // Fails only for the beacon that needs converting. + var converter = new RecordingConverter(null); + var repo = new BeaconRepository(this.db, converter); + + List requests = repo.toAccessoryRequests(Map.of( + "good-1", PLIST, + "bad", PLIST, + "good-2", PLIST + )).blockingFirst(); + + assertEquals("the two healthy beacons should still be fetched", 2, requests.size()); + for (AccessoryRequest request : requests) { + assertNotNull(request.getAccessoryJson()); + } + } + + /** + * The row's retained plist is the source of truth, not the caller's map, which can be + * stale. Guards the migration path where the caller passes whatever it had in memory. + */ + @Test + public void prefersStoredPlistOverCallerSuppliedOne() { + insertBeacon("beacon-a", "STORED-PLIST", null); + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + repo.toAccessoryRequests(Map.of("beacon-a", "CALLER-PLIST")).blockingFirst(); + + assertEquals(1, converter.calls.size()); + assertEquals("STORED-PLIST", converter.calls.get(0)); + } + + /** A beacon that was never imported must not blow up the batch. */ + @Test + public void handlesMissingRowGracefully() { + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Map.of("never-imported", PLIST)).blockingFirst(); + + // Falls back to the caller's plist since there is no row to read one from. + assertEquals(1, requests.size()); + assertEquals(CONVERTED_JSON, requests.get(0).getAccessoryJson()); + } + + /** No beacons requested means no work and no Python round trip. */ + @Test + public void emptyRequestDoesNoWork() { + var converter = new RecordingConverter(CONVERTED_JSON); + var repo = new BeaconRepository(this.db, converter); + + List requests = + repo.toAccessoryRequests(Collections.emptyMap()).blockingFirst(); + + assertTrue(requests.isEmpty()); + assertTrue(converter.calls.isEmpty()); + } + + /** + * The whole point of issue #30: the rolling-key alignment returned by a fetch has to + * land back in the database, or key drift re-emerges on the next fetch. + */ + @Test + public void storeFetchResultPersistsUpdatedAccessoryJson() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var repo = new BeaconRepository(this.db, new RecordingConverter(null)); + + final String updated = "{\"type\":\"accessory\",\"alignment_index\":4242}"; + var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( + Collections.emptyMap(), + Map.of("beacon-a", updated) + ); + + repo.storeFetchResult(fetchResult).blockingFirst(); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("updated key alignment must be persisted", updated, stored.accessoryJson); + } + + /** A null value in the fetch result must not wipe a good stored alignment. */ + @Test + public void storeFetchResultIgnoresNullUpdates() { + insertBeacon("beacon-a", PLIST, CONVERTED_JSON); + var repo = new BeaconRepository(this.db, new RecordingConverter(null)); + + var updates = new java.util.HashMap(); + updates.put("beacon-a", null); + var fetchResult = new dev.wander.android.opentagviewer.python.FetchResult( + Collections.emptyMap(), updates); + + repo.storeFetchResult(fetchResult).blockingFirst(); + + OwnedBeacon stored = this.db.ownedBeaconDao().getById("beacon-a"); + assertEquals("existing alignment must survive a null update", CONVERTED_JSON, stored.accessoryJson); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java new file mode 100644 index 00000000..d66d5e84 --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabaseMigrationTest.java @@ -0,0 +1,269 @@ +package dev.wander.android.opentagviewer.db.room; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.ContentValues; +import android.database.Cursor; + +import androidx.room.testing.MigrationTestHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +/** + * Regression tests for the v1 → v2 database upgrade. + *
+ * This exists because the failure mode is invisible on a fresh install: a version bump + * without a registered {@link androidx.room.migration.Migration} only throws for users + * who already have a database, and it throws at first DB access rather than at build + * time. Every existing install would have crashed with + * {@code IllegalStateException: A migration from 1 to 2 was required but not found}. + *
+ * The migration must also be non-destructive: falling back to a destructive migration + * here would wipe imported beacons and location history, forcing users through the + * macOS export wizard again — the exact thing this app exists to avoid. + */ +@RunWith(AndroidJUnit4.class) +public class OpenTagViewerDatabaseMigrationTest { + private static final String TEST_DB = "migration-test-db"; + + private static final String BEACON_ID = "ABCDEF01-2345-6789-ABCD-EF0123456789"; + private static final String BEACON_PLIST = ""; + + @Rule + public MigrationTestHelper helper = new MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + OpenTagViewerDatabase.class + ); + + /** + * The core guarantee: an existing v1 database survives the upgrade with its rows and + * column values intact, and gains a NULL {@code accessory_json}. + */ + @Test + public void migrate1To2_preservesExistingBeacons() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, /* validateDroppedTables= */ true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query( + "SELECT id, import_id, content, version, is_removed, accessory_json" + + " FROM OwnedBeacons WHERE id = ?", new Object[]{BEACON_ID})) { + + assertTrue("beacon row did not survive the migration", cursor.moveToFirst()); + assertEquals(BEACON_ID, cursor.getString(0)); + assertEquals(1L, cursor.getLong(1)); + assertEquals("plist content was altered by the migration", BEACON_PLIST, cursor.getString(2)); + assertEquals("1.0", cursor.getString(3)); + assertEquals(0, cursor.getInt(4)); + + // Pre-existing rows have no accessory JSON yet. BeaconRepository#toAccessoryRequests + // backfills it from the retained plist on the next fetch. + assertTrue("accessory_json should start NULL for migrated rows", cursor.isNull(5)); + assertFalse("expected exactly one row for this beacon", cursor.moveToNext()); + } + } + + /** + * The plist in {@code content} is what the lazy backfill re-converts from, so the + * migration must not drop it once {@code accessory_json} exists. Guards against a + * future "clean up the now-redundant column" change. + */ + @Test + public void migrate1To2_retainsPlistContentColumn() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT content FROM OwnedBeacons WHERE id = ?", new Object[]{BEACON_ID})) { + assertTrue(cursor.moveToFirst()); + assertNotNull("content must survive - the backfill re-reads it", cursor.getString(0)); + } + } + + /** + * Multiple beacons across imports, including a soft-deleted one, all survive. Row + * count is the thing users would notice going wrong. + */ + @Test + public void migrate1To2_preservesAllRowsIncludingRemoved() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertImport(db, 2L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + insertOwnedBeaconV1(db, "beacon-c", 2L, BEACON_PLIST, false); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons were lost during the migration", 3, cursor.getInt(0)); + } + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons WHERE is_removed = 1")) { + assertTrue(cursor.moveToFirst()); + assertEquals("soft-delete state was not preserved", 1, cursor.getInt(0)); + } + } + + /** + * An empty v1 database (installed but never imported) must also upgrade cleanly. + */ + @Test + public void migrate1To2_handlesEmptyDatabase() throws IOException { + helper.createDatabase(TEST_DB, 1).close(); + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals(0, cursor.getInt(0)); + } + } + + /** + * Location history is the expensive-to-rebuild data, and it is not what the migration + * touches — so it is exactly what a careless destructive fallback would silently eat. + */ + @Test + public void migrate1To2_preservesLocationHistory() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + insertLocationReport(db, "hash-1", BEACON_ID, 1700000000000L); + insertLocationReport(db, "hash-2", BEACON_ID, 1700000600000L); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + + try (Cursor cursor = db.query( + "SELECT COUNT(*) FROM LocationReport WHERE beacon_id = ?", new Object[]{BEACON_ID})) { + assertTrue(cursor.moveToFirst()); + assertEquals("location history was lost during the migration", 2, cursor.getInt(0)); + } + } + + /** + * v2 → v3 adds alignment_plist. Existing rows keep their data and gain a NULL column, + * which is correct: their exports predate format 0.0.2 and have no alignment record. + */ + @Test + public void migrate2To3_preservesExistingBeacons() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, BEACON_ID, 1L, BEACON_PLIST, false); + } + + // reach v2 the way a real device would, then apply the new one + helper.runMigrationsAndValidate(TEST_DB, 2, true, OpenTagViewerDatabase.MIGRATION_1_2); + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 3, true, OpenTagViewerDatabase.MIGRATION_2_3); + + try (Cursor cursor = db.query( + "SELECT content, accessory_json, alignment_plist FROM OwnedBeacons WHERE id = ?", + new Object[]{BEACON_ID})) { + assertTrue("beacon row did not survive v2 to v3", cursor.moveToFirst()); + assertEquals(BEACON_PLIST, cursor.getString(0)); + assertTrue("accessory_json should still be NULL", cursor.isNull(1)); + assertTrue("alignment_plist starts NULL for pre-0.0.2 exports", cursor.isNull(2)); + } + } + + /** + * The case that actually reaches users: someone who never took the v2 release and + * upgrades straight from v1. Both migrations have to run in sequence. + */ + @Test + public void migrate1To3_directUpgradePreservesEverything() throws IOException { + try (SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1)) { + insertImport(db, 1L); + insertOwnedBeaconV1(db, "beacon-a", 1L, BEACON_PLIST, false); + insertOwnedBeaconV1(db, "beacon-b", 1L, BEACON_PLIST, true); + insertLocationReport(db, "hash-1", "beacon-a", 1700000000000L); + } + + SupportSQLiteDatabase db = helper.runMigrationsAndValidate( + TEST_DB, 3, true, + OpenTagViewerDatabase.MIGRATION_1_2, + OpenTagViewerDatabase.MIGRATION_2_3); + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM OwnedBeacons")) { + assertTrue(cursor.moveToFirst()); + assertEquals("beacons lost on a direct v1 to v3 upgrade", 2, cursor.getInt(0)); + } + + try (Cursor cursor = db.query("SELECT COUNT(*) FROM LocationReport")) { + assertTrue(cursor.moveToFirst()); + assertEquals("location history lost on a direct v1 to v3 upgrade", 1, cursor.getInt(0)); + } + + try (Cursor cursor = db.query( + "SELECT accessory_json, alignment_plist FROM OwnedBeacons WHERE id = ?", + new Object[]{"beacon-a"})) { + assertTrue(cursor.moveToFirst()); + assertTrue(cursor.isNull(0)); + assertTrue(cursor.isNull(1)); + } + } + + private static void insertImport(SupportSQLiteDatabase db, long id) { + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("version", "1.0"); + values.put("imported_at", 1700000000000L); + values.put("exported_at", 1699999999000L); + values.put("source_user", "someone@example.com"); + values.put("via", "test"); + db.insert("Import", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } + + private static void insertOwnedBeaconV1( + SupportSQLiteDatabase db, String id, long importId, String content, boolean isRemoved) { + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("import_id", importId); + values.put("content", content); + values.put("version", "1.0"); + values.put("is_removed", isRemoved ? 1 : 0); + db.insert("OwnedBeacons", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } + + private static void insertLocationReport( + SupportSQLiteDatabase db, String hashId, String beaconId, long timestamp) { + ContentValues values = new ContentValues(); + values.put("hash_id", hashId); + values.put("beacon_id", beaconId); + values.put("published_at", timestamp); + values.put("description", "test report"); + values.put("timestamp", timestamp); + values.put("confidence", 2); + values.put("latitude", 52.379189); + values.put("longitude", 4.899431); + values.put("horizontal_accuracy", 10); + values.put("status", 0); + values.put("last_update", timestamp); + db.insert("LocationReport", android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT, values); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java new file mode 100644 index 00000000..cc355f9c --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/maps/TagCardLayoutTest.java @@ -0,0 +1,214 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.wander.android.opentagviewer.R; + +/** + * Layout tests for the tag cards along the bottom of the map. + *
+ * These cover the regressions that can only be seen, and that have each come back more than + * once: cards of different heights sitting next to each other, and a card whose shadow is + * clipped by its container. Nothing throws when they break - the app runs, the data is right, + * it just looks wrong - so the only previous detector was somebody noticing on a phone. + *
+ * Deliberately narrow. This inflates the real card layout into a container built the same way + * {@code activity_maps.xml} builds the scroll row, and measures it. No map, no network, no + * database and no Apple account, so it is quick and cannot flake on anything external. + *
+ * Inflation and measurement run on the main thread - the card contains a Material + * CircularProgressIndicator, whose drawable starts an animator and throws "Animators may only + * be run on Looper threads" anywhere else. Assertions stay on the test thread, so a failure is + * reported as a failure rather than as a crash on the main looper. + *
+ * Run with {@code ./gradlew :app:testEmulatorDebugAndroidTest}. + */ +@RunWith(AndroidJUnit4.class) +public class TagCardLayoutTest { + + /** Roughly a card's width on a phone, after MapsActivity's 80px inset. */ + private static final int CARD_WIDTH_PX = 1000; + private static final int SCREEN_WIDTH_PX = 1080; + + private static final String SHORT_ADDRESS = "Amsterdam, Netherlands"; + private static final String LONG_ADDRESS = + "Nieuwezijds Voorburgwal 147, 1012 RJ Amsterdam, Noord-Holland, Netherlands"; + + private Context context; + + @Before + public void setUp() { + // The card uses Material components, which throw without a Material theme. + this.context = new ContextThemeWrapper( + getInstrumentation().getTargetContext(), R.style.Theme_OpenTagViewer); + } + + /** One card's content, and the height MapsActivity would give it. */ + private static final class CardSpec { + final String name; + final String address; + final int height; + + CardSpec(final String name, final String address) { + this(name, address, ViewGroup.LayoutParams.MATCH_PARENT); + } + + CardSpec(final String name, final String address, final int height) { + this.name = name; + this.address = address; + this.height = height; + } + } + + /** + * Builds the row the way {@code MapsActivity.updateBeaconCards} does, measures it, and + * returns each card's measured height. + */ + private List measureHeights(final CardSpec... specs) { + final List heights = new ArrayList<>(); + + getInstrumentation().runOnMainSync(() -> { + // Matches tags_scroll_container: a wrap_content horizontal LinearLayout. + final LinearLayout row = new LinearLayout(this.context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + for (final CardSpec spec : specs) { + final FrameLayout card = (FrameLayout) LayoutInflater.from(this.context) + .inflate(R.layout.maps_tag_card, null); + row.addView(card); + + final ViewGroup.LayoutParams params = card.getLayoutParams(); + params.width = CARD_WIDTH_PX; + params.height = spec.height; + card.setLayoutParams(params); + + ((TextView) card.findViewById(R.id.device_name)).setText(spec.name); + ((TextView) card.findViewById(R.id.device_location)).setText(spec.address); + ((TextView) card.findViewById(R.id.device_last_update)) + .setText("Last Updated: 2 minutes ago"); + } + + row.measure( + View.MeasureSpec.makeMeasureSpec(SCREEN_WIDTH_PX, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + row.layout(0, 0, row.getMeasuredWidth(), row.getMeasuredHeight()); + + for (int i = 0; i < row.getChildCount(); i++) { + heights.add(row.getChildAt(i).getMeasuredHeight()); + } + }); + + return heights; + } + + // --- uniform height ------------------------------------------------------------------- + + @Test + public void everyCardIsTheSameHeightWhateverItsContent() { + // A one-line address next to a two-line one is what made the row ragged. + final List heights = this.measureHeights( + new CardSpec("Keys", SHORT_ADDRESS), + new CardSpec("Shane's Wallet", LONG_ADDRESS), + new CardSpec("Backpack", "Groningen")); + + final int tallest = heights.stream().mapToInt(Integer::intValue).max().orElse(0); + assertTrue("cards should have a real height", tallest > 0); + for (int i = 0; i < heights.size(); i++) { + assertEquals("card " + i + " differs from the tallest", tallest, (int) heights.get(i)); + } + } + + @Test + public void aLongDeviceNameDoesNotMakeItsCardTaller() { + final List heights = this.measureHeights( + new CardSpec("Bag", "Amsterdam"), + new CardSpec("An extremely long tag name that will certainly wrap onto more lines", + "Amsterdam")); + + assertEquals(heights.get(0), heights.get(1)); + } + + /** + * Why the height has to be set at all. + *
+ * MapsActivity inflates with a null root, which drops the layout's own height, and the + * container hands out WRAP_CONTENT by default. MATCH_PARENT inside a wrap_content + * horizontal LinearLayout is what triggers forceUniformHeight, which re-measures every + * child to the tallest. This pins that reasoning so nobody "tidies" the assignment away. + */ + @Test + public void wrapContentIsWhatMadeTheRowRagged() { + final List heights = this.measureHeights( + new CardSpec("Keys", SHORT_ADDRESS, ViewGroup.LayoutParams.WRAP_CONTENT), + new CardSpec("Shane's Wallet", LONG_ADDRESS, ViewGroup.LayoutParams.WRAP_CONTENT)); + + assertNotEquals( + "with WRAP_CONTENT the cards are expected to differ - if this now passes, " + + "forceUniformHeight is no longer what keeps the row even, and " + + "everyCardIsTheSameHeightWhateverItsContent is passing for some other reason", + heights.get(0), heights.get(1)); + } + + @Test + public void aSingleCardStillHasAHeight() { + assertTrue(this.measureHeights(new CardSpec("Keys", "Amsterdam")).get(0) > 0); + } + + // --- the shadow ----------------------------------------------------------------------- + + /** + * The cards are elevated, so their shadow is drawn outside their own bounds. With the + * scroll row clipping, the shadow is sliced off along the edges - which reads as a card + * with a hard border rather than as a bug. + */ + @Test + public void theScrollRowDoesNotClipTheCardShadows() { + final AtomicBoolean rowClipsChildren = new AtomicBoolean(true); + final AtomicBoolean rowClipsToPadding = new AtomicBoolean(true); + final AtomicBoolean areaClipsChildren = new AtomicBoolean(true); + final AtomicBoolean areaClipsToPadding = new AtomicBoolean(true); + + getInstrumentation().runOnMainSync(() -> { + final View activityMaps = LayoutInflater.from(this.context) + .inflate(R.layout.activity_maps, null); + + final ViewGroup row = activityMaps.findViewById(R.id.tags_scroll_container); + rowClipsChildren.set(row.getClipChildren()); + rowClipsToPadding.set(row.getClipToPadding()); + + final ViewGroup area = activityMaps.findViewById(R.id.tags_scrollable_area); + areaClipsChildren.set(area.getClipChildren()); + areaClipsToPadding.set(area.getClipToPadding()); + }); + + assertFalse("tags_scroll_container must not clip its children", rowClipsChildren.get()); + assertFalse("tags_scroll_container must not clip to padding", rowClipsToPadding.get()); + assertFalse("tags_scrollable_area must not clip its children", areaClipsChildren.get()); + assertFalse("tags_scrollable_area must not clip to padding", areaClipsToPadding.get()); + } +} diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java index 95e4992f..7424b928 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/AppCryptographyUtilTest.java @@ -3,16 +3,19 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.util.Collections; -import java.util.List; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; @@ -23,6 +26,33 @@ public class AppCryptographyUtilTest { public static final String TEST_KEYSTORE_ALIAS = "__testing_alias__"; + /** Matches AppCryptographyUtil.ANDROID_KEYSTORE, which is private to that class. */ + private static final String ANDROID_KEYSTORE = "AndroidKeyStore"; + + /** + * Keys in the AndroidKeyStore outlive the process, the test run and app upgrades - only + * uninstalling clears them. Cleaning up before as well as after means a crashed or killed + * earlier run cannot leave behind a key that this one would silently reuse. + */ + @Before + public void removeTestKeyBeforeTest() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + deleteTestKey(); + } + + @After + public void removeTestKeyAfterTest() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + deleteTestKey(); + } + + private static void deleteTestKey() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + + if (keyStore.containsAlias(TEST_KEYSTORE_ALIAS)) { + keyStore.deleteEntry(TEST_KEYSTORE_ALIAS); + } + } + @Test public void testEncryptDecrypt() throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { var instance = new AppCryptographyUtil(); @@ -38,8 +68,14 @@ public void testEncryptDecrypt() throws CertificateException, KeyStoreException, assertEquals(TEST_JSON, new String(decrypted, StandardCharsets.UTF_8)); + // Only that our own alias is there. The AndroidKeyStore is shared by everything + // running under the app's UID, so asserting on the whole list made this test pass on a + // clean device and fail on any device that had ever signed in - the login flow leaves + // AppKeyStoreConstants.KEYSTORE_ALIAS_ACCOUNT behind. var aliases = Collections.list(instance.getAliases()); - assertEquals(List.of(TEST_KEYSTORE_ALIAS), aliases); + assertTrue( + "Expected the keystore to contain " + TEST_KEYSTORE_ALIAS + ", got " + aliases, + aliases.contains(TEST_KEYSTORE_ALIAS)); byte[] flattened = result.flatten(); diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java new file mode 100644 index 00000000..4997b93f --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtilTest.java @@ -0,0 +1,221 @@ +package dev.wander.android.opentagviewer.util.parse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.net.Uri; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; +import dev.wander.android.opentagviewer.db.repo.model.ImportData; + +/** + * Covers the zip parsing stage of an import, and in particular whether a KeyAlignmentRecord + * survives the trip from the zip into OwnedBeacon.alignmentPlist. + *
+ * This was previously untested, and the whole 0.0.2 path was silently dead: the file type + * enum, the switch case and the database column all existed, but there was no entry for + * KEY_ALIGNMENT_RECORD in the pattern map, so every alignment record was logged as an + * unexpected file and dropped. Nothing failed - imports just quietly kept searching each + * tag's entire key history. + *
+ * These tests deliberately do not exercise plist to accessory-JSON conversion. That call goes + * through Chaquopy and is covered by the pytest suite for main.py; the importer catches its + * failures and leaves accessoryJson null for the lazy backfill to retry, so it does not need + * a Python runtime here. + */ +@RunWith(AndroidJUnit4.class) +public class AppleZipImporterUtilTest { + + /** Uppercase v4 UUIDs - the matchers are case sensitive, as macOS writes them uppercase. */ + private static final String BEACON_A = "0FB0AEAC-C083-405E-A979-4AA6A73F5C56"; + private static final String BEACON_B = "46E881A1-3CD9-4965-AEA9-2D95414661E7"; + private static final String RECORD_ID = "19FDE267-DE22-4B9A-BC44-E22C5970FCDC"; + + private static final String YAML_0_0_2 = + "version: 0.0.2\n" + + "exportTimestamp: 1740685990163\n" + + "via: test\n" + + "sourceUser: tester\n"; + + private static final String YAML_0_0_1 = + "version: 0.0.1\n" + + "exportTimestamp: 1740685990163\n" + + "via: test\n" + + "sourceUser: tester\n"; + + private Context context; + private File zipFile; + + @Before + public void setUp() { + this.context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @After + public void tearDown() { + if (this.zipFile != null && this.zipFile.exists() && !this.zipFile.delete()) { + this.zipFile.deleteOnExit(); + } + } + + @Test + public void readsTheAlignmentRecordFromA002Export() throws IOException { + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_A + "/" + RECORD_ID + ".plist", alignmentPlist(4321)); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + OwnedBeacon beacon = result.getOwnedBeacons().get(0); + + assertEquals(BEACON_A, beacon.id); + assertNotNull( + "The alignment record was dropped during import - check the KEY_ALIGNMENT_RECORD" + + " entry in AppleZipImporterUtil.MATCHERS", + beacon.alignmentPlist); + assertTrue( + "Expected the accessory's own alignment record, got: " + beacon.alignmentPlist, + beacon.alignmentPlist.contains("lastIndexObserved")); + } + + @Test + public void keysTheAlignmentRecordByAccessoryNotByRecordId() throws IOException { + // The record's own uuid appears in the filename too. Keying by it instead of by the + // parent directory would attach every record to the wrong accessory, or to none. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_A + "/" + RECORD_ID + ".plist", alignmentPlist(1111)); + + entries.put("OwnedBeacons/" + BEACON_B + ".plist", ownedBeaconPlist(BEACON_B)); + entries.put("BeaconNamingRecord/" + BEACON_B + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag B")); + entries.put("KeyAlignmentRecords/" + BEACON_B + "/" + RECORD_ID + ".plist", alignmentPlist(2222)); + + ImportData result = importZipOf(entries); + + assertEquals(2, result.getOwnedBeacons().size()); + for (OwnedBeacon beacon : result.getOwnedBeacons()) { + assertNotNull("No alignment record for " + beacon.id, beacon.alignmentPlist); + + final String expectedIndex = BEACON_A.equals(beacon.id) ? "1111" : "2222"; + assertTrue( + "Alignment record for " + beacon.id + " has the wrong index: " + + beacon.alignmentPlist, + beacon.alignmentPlist.contains(expectedIndex)); + } + } + + @Test + public void legacyExportsImportWithoutAnAlignmentRecord() throws IOException { + // Exports made before 0.0.2 carry no KeyAlignmentRecords at all. They must still + // import; the column stays null and main.py falls back to probing for the alignment. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_1); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + assertNull(result.getOwnedBeacons().get(0).alignmentPlist); + assertEquals("0.0.1", result.getOwnedBeacons().get(0).version); + } + + @Test + public void ignoresAnAlignmentRecordForAnUnknownAccessory() throws IOException { + // macOS can hold a KeyAlignmentRecord for an accessory that is no longer in + // OwnedBeacons. It has nothing to attach to and must not affect the rest of the import. + Map entries = new LinkedHashMap<>(); + entries.put("OPENTAGVIEWER.yml", YAML_0_0_2); + entries.put("OwnedBeacons/" + BEACON_A + ".plist", ownedBeaconPlist(BEACON_A)); + entries.put("BeaconNamingRecord/" + BEACON_A + "/" + RECORD_ID + ".plist", namingRecordPlist("Tag A")); + entries.put("KeyAlignmentRecords/" + BEACON_B + "/" + RECORD_ID + ".plist", alignmentPlist(9999)); + + ImportData result = importZipOf(entries); + + assertEquals(1, result.getOwnedBeacons().size()); + assertEquals(BEACON_A, result.getOwnedBeacons().get(0).id); + assertNull(result.getOwnedBeacons().get(0).alignmentPlist); + } + + // ----------------------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------------------- + + private ImportData importZipOf(final Map entries) throws IOException { + this.zipFile = File.createTempFile("otv-import-test", ".zip", this.context.getCacheDir()); + + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(this.zipFile))) { + for (Map.Entry entry : entries.entrySet()) { + out.putNextEntry(new ZipEntry(entry.getKey())); + out.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + } + + return new AppleZipImporterUtil(this.context).extractZip(Uri.fromFile(this.zipFile)); + } + + /** + * Shaped like a real OwnedBeacons plist but carrying no key material. The importer only + * stores this verbatim - parsing it is main.py's job and is covered by its own tests. + */ + private static String ownedBeaconPlist(final String beaconId) { + return "\n" + + "\n" + + "\n" + + "\tidentifier\n" + + "\t" + beaconId + "\n" + + "\tpairingDate\n" + + "\t2025-02-27T20:33:10Z\n" + + "\n" + + "\n"; + } + + private static String namingRecordPlist(final String name) { + return "\n" + + "\n" + + "\n" + + "\tname\n" + + "\t" + name + "\n" + + "\n" + + "\n"; + } + + /** The two fields that matter: where the rolling key index was, and when. */ + private static String alignmentPlist(final int lastIndexObserved) { + return "\n" + + "\n" + + "\n" + + "\tlastIndexObserved\n" + + "\t" + lastIndexObserved + "\n" + + "\tlastIndexObservationDate\n" + + "\t2026-08-01T12:00:00Z\n" + + "\n" + + "\n"; + } +} diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..f419eac3 --- /dev/null +++ b/app/src/debug/res/drawable/ic_launcher_background.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..43dd9d8e Binary files /dev/null and b/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..cb24b8d6 Binary files /dev/null and b/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..ba5e496d Binary files /dev/null and b/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..ec13a0e1 Binary files /dev/null and b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..83eaeba3 Binary files /dev/null and b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 610e3d87..8826f7cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,13 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + + + + + + + @@ -22,7 +29,7 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" + android:label="${appLabel}" android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" @@ -82,10 +89,16 @@ 2. Add this line, where YOUR_API_KEY is your API key: MAPS_API_KEY=YOUR_API_KEY --> + + + { Log.d(TAG, "Got error response from anisette server @ " + newUrl, error); @@ -242,9 +252,9 @@ private void hideLoading() { } private void onAnisetteUrlInputTyped(Boolean isValid) { - // typing overrides until explicitly validated by pressing the "confirm" - // checkmark to test the server - this.binding.setAllowServerConfNext(false); + // Let the user proceed after typing a syntactically valid URL. + // The actual connectivity test happens when the user continues. + this.binding.setAllowServerConfNext(isValid); } private void handleAuth(LoginActivityState state) { @@ -274,11 +284,21 @@ private void handleAuth(LoginActivityState state) { public void onClickToLoginAccount(View view) { Log.d(TAG, "Clicked onwards to account login!"); - // TODO: make a nice transition - if (this.binding.getAllowServerConfNext()) { + MaterialAutoCompleteTextView urlTextInput = findViewById(R.id.anisetteServerUrl); + final String currentInput = Optional.ofNullable(urlTextInput.getText()) + .map(CharSequence::toString) + .map(String::trim) + .orElse(""); + + if (!this.sharedMainSettingsManager.validateAnisetteUrl(currentInput)) { + this.binding.setAllowServerConfNext(false); + return; + } + + this.testAndSaveAnisetteUrl(currentInput, () -> { this.getUiState().setCurrentPage(PAGE.LOGIN); this.showAccountLoginAuthOptions(); - } + }); } public void onClickBackToAnisetteSettings(View view) { @@ -702,6 +722,30 @@ private void updateLocale(final String newLocale) { Log.i(TAG, "Updating app settings language"); } + private void updateMapProvider(final String newProvider) { + // AMap needs a key the user supplies themselves, so selecting it here without one + // would silently save a provider that can only render a blank map. Prompt instead, + // and only apply the choice once a key exists. + if ("amap".equals(newProvider) && !this.getUserSettings().hasAmapApiKey()) { + AmapApiKeyDialog.show(this, this.getUserSettings().getAmapApiKey(), enteredKey -> { + if (enteredKey == null) { + Toast.makeText(this, R.string.amap_key_required, Toast.LENGTH_LONG).show(); + this.sharedMainSettingsManager.setupMapProviderField(); + return; + } + this.getUserSettings().setAmapApiKey(enteredKey); + this.getUserSettings().setMapProvider(newProvider); + this.saveSettings(); + Log.i(TAG, "Updating app settings map provider to " + newProvider); + }); + return; + } + + this.getUserSettings().setMapProvider(newProvider); + this.saveSettings(); + Log.i(TAG, "Updating app settings map provider to " + newProvider); + } + private void saveSettings() { var asyncOp = this.userSettingsRepo.storeUserSettings(this.getUserSettings()) .subscribe( @@ -731,4 +775,4 @@ enum SETUP_STATUS { SERVER_UNAVAILABLE, OK; } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java index 391d0190..5f9112e1 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/HistoryViewActivity.java @@ -40,6 +40,13 @@ import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.gms.maps.model.Polyline; import com.google.android.gms.maps.model.PolylineOptions; + +import dev.wander.android.opentagviewer.ui.maps.IMapProvider; +import dev.wander.android.opentagviewer.ui.maps.MapProviderFactory; +import dev.wander.android.opentagviewer.ui.maps.MapMarker; +import dev.wander.android.opentagviewer.ui.maps.MapPolyline; +import dev.wander.android.opentagviewer.ui.maps.GoogleMapProvider; +import dev.wander.android.opentagviewer.ui.maps.AMapProvider; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.button.MaterialButton; import com.google.android.material.datepicker.CalendarConstraints; @@ -86,7 +93,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers; import lombok.Synchronized; -public class HistoryViewActivity extends AppCompatActivity implements OnMapReadyCallback { +public class HistoryViewActivity extends AppCompatActivity implements IMapProvider.OnMapReadyCallback { private static final String TAG = HistoryViewActivity.class.getSimpleName(); private static final float HISTORY_SHEET_HALF_EXPANDED_RATIO = 0.4f; @@ -100,7 +107,8 @@ public class HistoryViewActivity extends AppCompatActivity implements OnMapReady private static final long SEVEN_DAYS_IN_MS = DAY_IN_MS * 7; - private GoogleMap map; + private IMapProvider mapProvider; + private GoogleMap map; // 保留用于向后兼容 private BeaconRepository beaconRepo; private UserSettingsRepository userSettingsRepo; @@ -127,9 +135,9 @@ public class HistoryViewActivity extends AppCompatActivity implements OnMapReady private int daysBack = 0; private long currentBeginningOfDay = -1; - private Polyline currentHistoryLineOutline = null; - private Polyline currentHistoryLine = null; - private Marker singleCoordMarker = null; + private String currentHistoryLineOutlineId = null; + private String currentHistoryLineId = null; + private String singleCoordMarkerId = null; private Button moveLeftButton; private Button moveRightButton; @@ -197,26 +205,67 @@ protected void onCreate(Bundle savedInstanceState) { recyclerView.setItemAnimator(null); // Obtain the SupportMapFragment and get notified when the map is ready to be used. - SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager() - .findFragmentById(R.id.history_map); - mapFragment.getMapAsync(this); + // 根据用户设置创建地图提供商 + String mapProviderType = this.userSettings.getMapProvider(); + this.mapProvider = MapProviderFactory.create(mapProviderType); + + // 初始化地图 + this.mapProvider.initialize(this, R.id.history_map, this); } @Override - public void onMapReady(GoogleMap googleMap) { - this.map = googleMap; + public void onMapReady(IMapProvider provider) { + this.mapProvider = provider; - if (this.userSettings.hasDarkThemeEnabled()) { - // DARK THEME map - map.setMapStyle(MapStyleOptions.loadRawResourceStyle(this.getApplicationContext(), R.raw.map_dark_style)); + // 如果是Google Maps,保留向后兼容 + if (provider instanceof GoogleMapProvider) { + this.map = ((GoogleMapProvider) provider).getGoogleMap(); } + mapProvider.setMapStyle(this.getPreferredMapStyle()); + // move to same position that we left when we went to the history page from the main page - this.map.moveCamera(CameraUpdateFactory.newLatLngZoom( - new LatLng(this.defaultLatitude, this.defaultLongitude), this.defaultZoom)); + this.mapProvider.moveCamera(this.defaultLatitude, this.defaultLongitude, this.defaultZoom); this.fetchAndUpdateDataForCurrentDay(); } + + @Override + protected void onResume() { + super.onResume(); + this.userSettings = this.userSettingsRepo.getUserSettings(); + if (this.mapProvider != null) { + this.mapProvider.setMapStyle(this.getPreferredMapStyle()); + } + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onResume(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onPause(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onDestroy(); + } + } + + private IMapProvider.MapStyle getPreferredMapStyle() { + if (this.userSettings == null || this.userSettings.getUseDarkTheme() == null) { + return IMapProvider.MapStyle.FOLLOW_SYSTEM; + } + return this.userSettings.getUseDarkTheme() + ? IMapProvider.MapStyle.DARK + : IMapProvider.MapStyle.LIGHT; + } private void fetchAndUpdateDataForCurrentDay() { moveLeftButton.setClickable(false); // temp disable @@ -324,7 +373,9 @@ private Observable> fetchReports(final long beginning final boolean isForToday = this.daysBack == 0; var reqData = Map.of(this.beaconId, this.beaconInformation.getOwnedBeaconPlistRaw()); - var asyncReq = this.appleService.getReportsBetween(reqData, beginningOfDay, endOfDay); + // asyncReq emits Observable (reports + updated accessory state per beacon) + var asyncReq = this.beaconRepo.toAccessoryRequests(reqData) + .flatMap(requests -> this.appleService.getReportsBetween(requests, beginningOfDay, endOfDay)); final long now = System.currentTimeMillis(); if (beginningOfDay < now - SEVEN_DAYS_IN_MS) { @@ -337,7 +388,7 @@ private Observable> fetchReports(final long beginning Log.d(TAG, "Going to perform a merged localdb + remote fetch for beaconId=" + beaconId + " location data in range: " + beginningOfDay + "-" + endOfDay); return Observable.zip( // try to fetch remotely anyways and combine uniquely later - asyncReq.flatMap(this.beaconRepo::storeToLocationCache).map(locations -> locations.get(beaconId)), + asyncReq.flatMap(this.beaconRepo::storeFetchResult).map(locations -> locations.get(beaconId)), // also try to fetch from DB for same time range asyncDB, (locationsRemote, locationsLocal) -> { @@ -360,14 +411,14 @@ private Observable> fetchReports(final long beginning Log.d(TAG, "Going to perform a fresh fetch for beaconId=" + beaconId + " location data in range: " + beginningOfDay + "-" + endOfDay); return asyncReq - .doOnNext(locations -> { + .doOnNext(fetchResult -> { // Don't cache the current day (it could still update)! if (!isForToday) { - MEMORY_REPORTS_CACHE.put(cacheKey, locations.get(beaconId)); + MEMORY_REPORTS_CACHE.put(cacheKey, fetchResult.getReports().get(beaconId)); } }) - .flatMap(locations -> this.storeLocationFetchToLocalDb(isForToday, beaconId, beginningOfDay).andThen(Observable.just(locations))) - .flatMap(this.beaconRepo::storeToLocationCache) + .flatMap(fetchResult -> this.storeLocationFetchToLocalDb(isForToday, beaconId, beginningOfDay).andThen(Observable.just(fetchResult))) + .flatMap(this.beaconRepo::storeFetchResult) .map(locations -> locations.get(beaconId)) .subscribeOn(Schedulers.computation()); } @@ -413,17 +464,19 @@ private synchronized void updateForNewLocationsList(final List reports) { this.historyItemsAdapter.notifyItemRangeInserted(0, this.locations.size()); // draw datapoints on map - var coords = this.locations.stream().map(rep -> new LatLng(rep.getLatitude(), rep.getLongitude())) + var coords = this.locations.stream() + .map(rep -> new MapPolyline.LatLng(rep.getLatitude(), rep.getLongitude())) .collect(Collectors.toList()); this.drawNewLines(coords); if (!coords.isEmpty()) { if (coords.size() == 1) { - this.animateCameraToPos(coords.get(0), SINGLE_MARKER_ZOOM, null); + MapPolyline.LatLng coord = coords.get(0); + this.animateCameraToPos(coord.getLatitude(), coord.getLongitude(), SINGLE_MARKER_ZOOM, null); } else { var bounds = this.determineBoundsForCurrent(); this.animateCameraToBoundingBox(bounds, FOCUS_PADDING); @@ -617,28 +672,44 @@ private LatLngBounds determineBoundsForCurrent() { ); } - private void drawNewLines(final List coords) { + private void drawNewLines(final List coords) { + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet, cannot draw lines"); + return; + } + final int numCoords = coords.size(); if (numCoords > 1) { - var optionsOutlineLine = new PolylineOptions() + // 清除旧的路径线 + this.cleanupOldLines(); + + // 添加轮廓线 + MapPolyline outlineLine = MapPolyline.builder() + .points(coords) + .id("history_line_outline") .color(this.getColor(R.color.maps_line_outline)) .width(OUTLINE_WIDTH) - .clickable(false) - .addAll(coords); - this.currentHistoryLineOutline = this.map.addPolyline(optionsOutlineLine); + .build(); + this.currentHistoryLineOutlineId = this.mapProvider.addPolyline(outlineLine); - var optionsPrimaryLine = new PolylineOptions() + // 添加主路径线 + MapPolyline primaryLine = MapPolyline.builder() + .points(coords) + .id("history_line_primary") .color(this.getColor(R.color.maps_line_primary)) .width(LINE_WIDTH) - .clickable(true) - .addAll(coords); - - this.currentHistoryLine = this.map.addPolyline(optionsPrimaryLine); + .build(); + this.currentHistoryLineId = this.mapProvider.addPolyline(primaryLine); } else if (numCoords == 1) { // if we just had a single item, then draw a single marker - var markerOptions = new MarkerOptions() - .position(coords.get(0)); - this.singleCoordMarker = this.map.addMarker(markerOptions); + MapPolyline.LatLng coord = coords.get(0); + MapMarker marker = MapMarker.builder() + .id("single_coord_marker") + .latitude(coord.getLatitude()) + .longitude(coord.getLongitude()) + .useDefaultIcon(true) + .build(); + this.singleCoordMarkerId = this.mapProvider.addMarker(marker); } } @@ -662,47 +733,58 @@ private void handleOnClickHistoryListItem(final HistoryItemsAdapter.ClickedItemI } // remove current - if (this.singleCoordMarker != null) { - this.singleCoordMarker.remove(); + if (this.singleCoordMarkerId != null && this.mapProvider != null) { + this.mapProvider.removeMarker(this.singleCoordMarkerId); } final boolean sheetIsFullyExpanded = bottomSheetBehavior.getState() == STATE_EXPANDED; // replace with new - var pos = new LatLng(clickedReport.getLatitude(), clickedReport.getLongitude()); - var markerOptions = new MarkerOptions().position(pos).title(locationName); - - this.singleCoordMarker = this.map.addMarker(markerOptions); - - if (sheetIsFullyExpanded) { - this.overrideMapPadding = true; - this.map.setPadding(0, 0, 0, bottomSheetBehavior.getPeekHeight()); - this.animateCameraToPos(pos, SINGLE_MARKER_ZOOM, () -> { - Log.d(TAG, "Undoing overrideMapPadding override!"); - this.overrideMapPadding = false; - }); - bottomSheetBehavior.setState(STATE_HALF_EXPANDED); - } else { - this.animateCameraToPos(pos, SINGLE_MARKER_ZOOM, null); + if (this.mapProvider != null) { + MapMarker marker = MapMarker.builder() + .id("single_coord_marker") + .latitude(clickedReport.getLatitude()) + .longitude(clickedReport.getLongitude()) + .title(locationName) + .useDefaultIcon(true) + .build(); + this.singleCoordMarkerId = this.mapProvider.addMarker(marker); + + if (sheetIsFullyExpanded) { + this.overrideMapPadding = true; + this.mapProvider.setPadding(0, 0, 0, bottomSheetBehavior.getPeekHeight()); + this.animateCameraToPos(clickedReport.getLatitude(), clickedReport.getLongitude(), SINGLE_MARKER_ZOOM, () -> { + Log.d(TAG, "Undoing overrideMapPadding override!"); + this.overrideMapPadding = false; + }); + bottomSheetBehavior.setState(STATE_HALF_EXPANDED); + } else { + this.animateCameraToPos(clickedReport.getLatitude(), clickedReport.getLongitude(), SINGLE_MARKER_ZOOM, null); + } } } private void animateCameraToBoundingBox(final LatLngBounds bounds, int padding) { - this.map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); + if (this.mapProvider == null || bounds == null) return; + + // 将LatLngBounds转换为点列表 + List points = new ArrayList<>(); + points.add(new MapPolyline.LatLng(bounds.southwest.latitude, bounds.southwest.longitude)); + points.add(new MapPolyline.LatLng(bounds.northeast.latitude, bounds.northeast.longitude)); + + // 使用GoogleMapProvider的特殊方法(如果可用) + if (this.mapProvider instanceof GoogleMapProvider) { + ((GoogleMapProvider) this.mapProvider).animateCameraToBounds(points, padding); + } else { + // 对于其他提供商,计算中心点和缩放级别 + double centerLat = (bounds.southwest.latitude + bounds.northeast.latitude) / 2.0; + double centerLon = (bounds.southwest.longitude + bounds.northeast.longitude) / 2.0; + this.mapProvider.animateCamera(centerLat, centerLon, this.defaultZoom, null); + } } - - private void animateCameraToPos(final LatLng pos, float zoom, Runnable onFinished) { - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom(pos, zoom), new GoogleMap.CancelableCallback() { - @Override - public void onCancel() { - if (onFinished != null) onFinished.run(); - } - - @Override - public void onFinish() { - if (onFinished != null) onFinished.run(); - } - }); + private void animateCameraToPos(double latitude, double longitude, float zoom, Runnable onFinished) { + if (this.mapProvider == null) return; + this.mapProvider.animateCamera(latitude, longitude, zoom, onFinished); } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java index 30d0992b..c3a6536f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/InformationActivity.java @@ -2,26 +2,42 @@ import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.util.Log; +import android.view.LayoutInflater; import android.view.View; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.TooltipCompat; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.databinding.DataBindingUtil; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.android.material.imageview.ShapeableImageView; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; import dev.wander.android.opentagviewer.databinding.ActivityInformationBinding; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.widget.FlowLayout; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; public class InformationActivity extends AppCompatActivity { private static final String TAG = InformationActivity.class.getSimpleName(); + /** Both generated by scripts/fetch_contributors.py; see the workflow that runs it. */ + private static final String CONTRIBUTORS_ASSET = "contributors.json"; + private static final String AVATAR_ASSET_DIR = "contributors"; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -47,6 +63,8 @@ protected void onCreate(Bundle savedInstanceState) { throw new RuntimeException(e); } + this.showContributors(); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); @@ -54,22 +72,104 @@ protected void onCreate(Bundle savedInstanceState) { }); } + /** + * Fills the avatar grid from the list bundled at build time. + *
+ * Read from assets rather than fetched, which is what + * {@code scripts/fetch_contributors.py} exists for. Calling the GitHub API here would be + * rate limited in practice - it allows 60 requests an hour per IP, and carrier-grade NAT + * puts a great many users behind each one - and it would hand every user's IP to GitHub + * on opening a screen that shows no remote content otherwise. + */ + private void showContributors() { + FlowLayout grid = findViewById(R.id.contributors_flow); + if (grid == null) { + return; + } + final List contributors = readContributors(); + if (contributors.isEmpty()) { + // Nothing to show, so give the space back to the rest of the page. + findViewById(R.id.contributors_scroll).setVisibility(View.GONE); + return; + } - public void onClickDeveloperWebsite(View view) { - Log.d(TAG, "Clicked Developer Website button"); + final LayoutInflater inflater = getLayoutInflater(); - var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); - assert properties != null; - final String developerWebsite = properties.getProperty("developerWebsite"); + for (Contributor contributor : contributors) { + ShapeableImageView avatar = (ShapeableImageView) + inflater.inflate(R.layout.contributor_avatar, grid, false); - Uri devSite = Uri.parse(developerWebsite); - Intent intent = new Intent(Intent.ACTION_VIEW, devSite); + Bitmap bitmap = readAvatar(contributor.avatar); + if (bitmap == null) { + // The manifest and the images are generated together, so this only happens if + // one of them was edited by hand. Skip rather than show an empty circle. + Log.w(TAG, "No bundled avatar for " + contributor.login + "; skipping"); + continue; + } + avatar.setImageBitmap(bitmap); + avatar.setContentDescription(contributor.login); + + // Long press rather than a tooltip attribute: works below API 26 too. + TooltipCompat.setTooltipText(avatar, contributor.login); + + avatar.setOnClickListener(v -> openUrl(contributor.profileUrl)); + + grid.addView(avatar); + } + } + + private List readContributors() { + try (InputStream in = getAssets().open(CONTRIBUTORS_ASSET)) { + ContributorsFile parsed = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readValue(in, ContributorsFile.class); + + return parsed.contributors == null ? List.of() : parsed.contributors; + } catch (IOException e) { + Log.w(TAG, "Could not read " + CONTRIBUTORS_ASSET, e); + return List.of(); + } + } + + private Bitmap readAvatar(final String filename) { + if (filename == null || filename.isBlank()) { + return null; + } + + try (InputStream in = getAssets().open(AVATAR_ASSET_DIR + "/" + filename)) { + return BitmapFactory.decodeStream(in); + } catch (IOException e) { + Log.w(TAG, "Could not read avatar " + filename, e); + return null; + } + } + + private void openUrl(final String url) { + if (url == null || url.isBlank()) { + return; + } + + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); if (intent.resolveActivity(getPackageManager()) != null) { this.startActivity(intent); } } + /** Shape of assets/contributors.json. */ + public static class ContributorsFile { + public List contributors; + } + + public static class Contributor { + public String login; + public String profileUrl; + public String avatar; + public int commits; + } + + + public void onClickWiki(View view) { Log.d(TAG, "Clicked App Wiki button"); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index b3becf0f..2a0cf4b4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -4,6 +4,7 @@ import static android.Manifest.permission.ACCESS_FINE_LOCATION; import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static android.view.View.GONE; +import static android.view.View.INVISIBLE; import static android.view.View.VISIBLE; import static android.widget.Toast.LENGTH_LONG; import static android.widget.Toast.LENGTH_SHORT; @@ -26,11 +27,12 @@ import android.net.Uri; import android.os.Bundle; import android.os.Handler; +import android.os.Looper; import android.text.format.DateUtils; import android.util.Log; -import android.util.Pair; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.HorizontalScrollView; import android.widget.ImageButton; @@ -42,16 +44,16 @@ import com.google.android.gms.location.FusedLocationProviderClient; import com.google.android.gms.location.LocationServices; -import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.OnMapReadyCallback; -import com.google.android.gms.maps.SupportMapFragment; -import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.LatLng; -import com.google.android.gms.maps.model.MapStyleOptions; -import com.google.android.gms.maps.model.Marker; -import com.google.android.gms.maps.model.MarkerOptions; -import com.google.android.gms.maps.GoogleMap.OnMapClickListener; + +import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.maps.IMapProvider; +import dev.wander.android.opentagviewer.ui.maps.MapProviderFactory; +import dev.wander.android.opentagviewer.ui.maps.GoogleMapProvider; +import dev.wander.android.opentagviewer.ui.maps.AMapProvider; +import dev.wander.android.opentagviewer.ui.maps.MapMarker; +import dev.wander.android.opentagviewer.ui.maps.MapPolyline; import com.google.android.libraries.places.api.Places; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -84,7 +86,9 @@ import dev.wander.android.opentagviewer.db.repo.BeaconRepository; import dev.wander.android.opentagviewer.db.repo.model.ImportData; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.python.AccessoryRequest; import dev.wander.android.opentagviewer.python.PythonAppleService; +import dev.wander.android.opentagviewer.python.PythonAccountLoginException; import dev.wander.android.opentagviewer.python.PythonAuthService; import dev.wander.android.opentagviewer.db.repo.UserDataRepository; import dev.wander.android.opentagviewer.ui.maps.TagCardHelper; @@ -97,6 +101,11 @@ import dev.wander.android.opentagviewer.util.parse.AppleZipImporterUtil; import dev.wander.android.opentagviewer.util.parse.BeaconDataParser; import dev.wander.android.opentagviewer.util.parse.ZipImporterException; +import dev.wander.android.opentagviewer.util.rx.BeaconLocationHistory; +import dev.wander.android.opentagviewer.util.rx.LongFetchBannerState; +import dev.wander.android.opentagviewer.util.rx.MarkerFocus; +import dev.wander.android.opentagviewer.util.rx.RefreshPolicy; +import dev.wander.android.opentagviewer.util.rx.RxFlows; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -106,7 +115,7 @@ /** * TODO: this whole thing is a bit of a godclass. Decouple it. */ -public class MapsActivity extends AppCompatActivity implements OnMapReadyCallback, OnMapClickListener, GoogleMap.OnMarkerClickListener, ActivityCompat.OnRequestPermissionsResultCallback { +public class MapsActivity extends AppCompatActivity implements IMapProvider.OnMapReadyCallback, IMapProvider.OnMapClickListener, IMapProvider.OnMarkerClickListener, ActivityCompat.OnRequestPermissionsResultCallback { private static final String TAG = MapsActivity.class.getSimpleName(); private static final int LOCATION_PERMISSION_REQUEST_CODE = 1; @@ -117,15 +126,10 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac private static final long WAIT_BEFORE_REFETCH = 1000 * 60; // 1 MINUTE - private static final long ONE_HOUR_IN_MS = 1000 * 60 * 60; // 1 HOUR - private static final float CAMERA_ON_MAP_INITIAL_ZOOM = 16.0f; // see: https://developers.google.com/maps/documentation/android-sdk/views#zoom - private static final float MARKER_ZINDEX_DEFAULT = 0.0f; - - private static final float MARKER_ZINDEX_TOP = 10.0f; - - private GoogleMap map; + private IMapProvider mapProvider; + private GoogleMap map; // 保留用于向后兼容,逐步迁移 private ActivityMapsBinding binding; @@ -147,22 +151,53 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac private final Map beacons = new ConcurrentHashMap<>(); - private final Map> beaconLocations = new ConcurrentHashMap<>(); + /** Location history plus the "can this be drawn" rule. See BeaconLocationHistoryTest. */ + private final BeaconLocationHistory beaconLocations = new BeaconLocationHistory(); - private final Map currentMarkers = new ConcurrentHashMap<>(); + private final Map currentMarkers = new ConcurrentHashMap<>(); // 存储markerId - private Marker lastFocusedMarker = null; + /** Keeps the selected tag's marker above the overlapping ones. See MarkerFocusTest. */ + private final MarkerFocus markerFocus = new MarkerFocus(new MarkerFocus.Markers() { + @Override + public String markerIdFor(String beaconId) { + return MapsActivity.this.currentMarkers.get(beaconId); + } + + @Override + public void setZIndex(String markerId, float zIndex) { + if (MapsActivity.this.mapProvider != null) { + MapsActivity.this.mapProvider.setMarkerZIndex(markerId, zIndex); + } + } + }); private final Map dynamicCardsForTag = new ConcurrentHashMap<>(); private boolean initialFetchComplete = false; - private long last24HHistoryFetchAt = 0L; + + /** When a refresh is allowed, and how much history it should ask for. See RefreshPolicyTest. */ + private final RefreshPolicy refreshPolicy = new RefreshPolicy(WAIT_BEFORE_REFETCH, HOURS_TO_GO_BACK_24H); private TagListSwiperHelper tagListSwiperHelper = null; private final Handler refreshSchedulerHandler = new Handler(); private Runnable nextLocationRefreshTask = null; + /** + * How long a fetch may run before we tell the user it is still working. + *
+ * Long enough that an ordinary refresh never flashes the banner, short enough that a + * first fetch does not sit there looking frozen. + */ + private static final long SHOW_LONG_FETCH_BANNER_AFTER_MS = 6000L; + + private final Handler longFetchBannerHandler = new Handler(Looper.getMainLooper()); + private final Runnable showLongFetchBanner = () -> this.setLongFetchBannerVisible(true); + + /** All three are only ever touched on the main looper, so they need no synchronisation. */ + /** Banner bookkeeping. Touched only from the banner handler. See LongFetchBannerStateTest. */ + private final LongFetchBannerState bannerState = new LongFetchBannerState(); + private Optional lastCameraPositionOnLoad; private int windowWidth = 0; @@ -184,6 +219,10 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac Intent data = result.getData(); if (data != null && data.getBooleanExtra("requestSendToLogin", false)) { this.handleSendToLogin(); + return; + } + if (data != null && data.getBooleanExtra("mapProviderChanged", false)) { + this.recreate(); } } } @@ -197,6 +236,11 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac if (data != null && data.getBooleanExtra("isDeviceListChanged", false)) { this.handleDeviceListChanged(); } + // The empty state on the device list offers to import, but the picker and + // everything that runs after it live here, so it asks us to start it. + if (data != null && data.getBooleanExtra("startImport", false)) { + this.handleImport(); + } } } ); @@ -207,8 +251,8 @@ public class MapsActivity extends AppCompatActivity implements OnMapReadyCallbac if (result.getResultCode() == RESULT_OK) { Intent data = result.getData(); if (data != null && ( - data.getStringExtra("deviceWasRemoved") != null) - || data.getStringExtra("deviceWasChanged") != null) { + data.getStringExtra("deviceWasRemoved") != null + || data.getStringExtra("deviceWasChanged") != null)) { this.handleDeviceListChanged(); } } @@ -268,10 +312,15 @@ protected void onCreate(Bundle savedInstanceState) { .subscribe(pos -> { Log.d(TAG, "Got previous camera position to reset us to: " + pos); - pos.ifPresent(userMapCameraPosition -> this.map.moveCamera(CameraUpdateFactory.newLatLngZoom( - new LatLng(userMapCameraPosition.getLat(), userMapCameraPosition.getLon()), - userMapCameraPosition.getZoom() - ))); + pos.ifPresent(userMapCameraPosition -> { + if (this.mapProvider != null) { + this.mapProvider.moveCamera( + userMapCameraPosition.getLat(), + userMapCameraPosition.getLon(), + userMapCameraPosition.getZoom() + ); + } + }); }, error -> Log.e(TAG, "Failed to get last camera position!", error)); @@ -279,10 +328,12 @@ protected void onCreate(Bundle savedInstanceState) { this.windowWidth = this.getResources().getDisplayMetrics().widthPixels; - // Obtain the SupportMapFragment and get notified when the map is ready to be used. - SupportMapFragment mapFragment = (SupportMapFragment) getSupportFragmentManager() - .findFragmentById(R.id.map); - mapFragment.getMapAsync(this); + // 根据用户设置创建地图提供商 + String mapProviderType = this.userSettings.getMapProvider(); + IMapProvider tempProvider = MapProviderFactory.create(mapProviderType); + + // 初始化地图(注意:this.mapProvider 仅在 onMapReady 后赋值,避免初始化竞态) + tempProvider.initialize(this, R.id.map, this); } @@ -290,59 +341,68 @@ protected void onCreate(Bundle savedInstanceState) { /** * Manipulates the map once available. * This callback is triggered when the map is ready to be used. - * This is where we can add markers or lines, add listeners or move the camera. In this case, - * we just add a marker near Sydney, Australia. - * If Google Play services is not installed on the device, the user will be prompted to install - * it inside the SupportMapFragment. This method will only be triggered once the user has - * installed Google Play services and returned to the app. */ @Override - public void onMapReady(GoogleMap googleMap) { - map = googleMap; + public void onMapReady(IMapProvider provider) { + this.mapProvider = provider; + + // 如果是Google Maps,保留向后兼容 + if (provider instanceof GoogleMapProvider) { + this.map = ((GoogleMapProvider) provider).getGoogleMap(); + } - map.setOnMapClickListener(this); - map.setOnMarkerClickListener(this); + mapProvider.setOnMapClickListener(this); + mapProvider.setOnMarkerClickListener(this); - map.setPadding(0, 0, 0, GOOGLE_LOGO_PADDING_BOTTOM_PX); + mapProvider.setPadding(0, 0, 0, GOOGLE_LOGO_PADDING_BOTTOM_PX); // We don't want to use the default button. We have a custom button - map.getUiSettings().setMyLocationButtonEnabled(false); - map.getUiSettings().setRotateGesturesEnabled(false); // no rotation (mostly bc very annoying to reset) - map.getUiSettings().setCompassEnabled(false); // not needed due to no rotation being allowed - map.getUiSettings().setMapToolbarEnabled(false); // we have a custom button for this - - if (this.userSettings.hasDarkThemeEnabled()) { - // DARK THEME map - map.setMapStyle(MapStyleOptions.loadRawResourceStyle(this.getApplicationContext(), R.raw.map_dark_style)); - } + mapProvider.setMyLocationButtonEnabled(false); + mapProvider.setRotateGesturesEnabled(false); // no rotation (mostly bc very annoying to reset) + mapProvider.setCompassEnabled(false); // not needed due to no rotation being allowed + mapProvider.setMapToolbarEnabled(false); // we have a custom button for this + + mapProvider.setMapStyle(this.getPreferredMapStyle()); this.enableMyLocation(false); + + // 地图准备好后,刷新一次当前显示的信标位置 + this.showLastDeviceLocations(); } @Override protected void onPause() { super.onPause(); - if (this.map != null) { - var pos = this.map.getCameraPosition(); - var async = this.userDataRepository.storeLastCameraPosition( - UserMapCameraPosition.builder() - .zoom(pos.zoom) - .lat(pos.target.latitude) - .lon(pos.target.longitude) - .build() - ).subscribe( - success -> Log.d(TAG, "Success storing last camera position!"), - error -> Log.e(TAG, "Error storing last camera position!", error)); + if (this.mapProvider != null) { + IMapProvider.CameraPosition pos = this.mapProvider.getCameraPosition(); + if (pos != null) { + var async = this.userDataRepository.storeLastCameraPosition( + UserMapCameraPosition.builder() + .zoom(pos.getZoom()) + .lat(pos.getLatitude()) + .lon(pos.getLongitude()) + .build() + ).subscribe( + success -> Log.d(TAG, "Success storing last camera position!"), + error -> Log.e(TAG, "Error storing last camera position!", error)); + } // cleanup location refresh task refreshSchedulerHandler.removeCallbacks(this.nextLocationRefreshTask); this.nextLocationRefreshTask = null; } + + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onPause(); + } } @Override - public void onMapClick(LatLng point) { - Log.i(TAG, "tapped, point=" + point); + public void onMapClick(double latitude, double longitude) { + Log.i(TAG, "tapped, point=(" + latitude + ", " + longitude + ")"); // TODO: hide UI elements when this occurs! } @@ -350,10 +410,30 @@ public void onMapClick(LatLng point) { protected void onResume() { super.onResume(); + this.userSettings = this.userSettingsRepo.getUserSettings(); + if (this.mapProvider != null) { + this.mapProvider.setMapStyle(this.getPreferredMapStyle()); + } + // TODO: when a user changes their anisette URL in settings and returns here, this should be able to deal with querying the new URL this.refreshIfAllowed(); this.reSchedulePeriodicTagLocationRefresher(); + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onResume(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // 调用高德地图的生命周期方法 + if (this.mapProvider instanceof AMapProvider) { + ((AMapProvider) this.mapProvider).onDestroy(); + } } private void reSchedulePeriodicTagLocationRefresher() { @@ -369,30 +449,23 @@ private void reSchedulePeriodicTagLocationRefresher() { } private void refreshIfAllowed() { - if (!this.isAppleServiceInitialised()) { - Log.d(TAG, "AppleService was not initialised yet, so we can't refresh"); - return; - } + final long now = System.currentTimeMillis(); - if (!this.initialFetchComplete) { - Log.d(TAG, "Skipping refresh due to not having fully initialised yet"); - return; - } + final RefreshPolicy.Decision decision = this.refreshPolicy.decide( + now, + this.isAppleServiceInitialised(), + this.initialFetchComplete, + PythonAppleService.isBusy()); - final long now = System.currentTimeMillis(); - if (now < this.last24HHistoryFetchAt + WAIT_BEFORE_REFETCH) { - Log.d(TAG, String.format( - "We will not re-fetch Beacon history as less than %d ms (actual: %d ms) have passed since the last history fetch", - WAIT_BEFORE_REFETCH, - (now - this.last24HHistoryFetchAt) - )); + if (!decision.shouldRefresh()) { + Log.d(TAG, String.format("Skipping the scheduled refresh: %s (%s)", + decision.reason(), this.refreshPolicy.describeTimeSinceLastFetch(now))); return; } Log.d(TAG, "Performing automatic scheduled refresh of data for all tags..."); this.fetchAndUpdateCurrentBeacons(); Log.d(TAG, "Automatic scheduled refresh complete! Next automatic refresh will be in " + WAIT_BEFORE_REFETCH + " ms"); - //Toast.makeText(this, "Performing automatic periodic refresh...", LENGTH_SHORT).show(); } public void onClickMoreSettings(View view) @@ -450,9 +523,14 @@ private void animateCameraToMyLocation() { } Log.d(TAG, "Navigating to current user position on the map..."); - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom( - new LatLng(location.getLatitude(), location.getLongitude()), - CAMERA_ON_MAP_INITIAL_ZOOM)); + if (this.mapProvider != null) { + this.mapProvider.animateCamera( + location.getLatitude(), + location.getLongitude(), + CAMERA_ON_MAP_INITIAL_ZOOM, + null + ); + } }); } else { @@ -474,12 +552,12 @@ public void onClickNavigateTo(View view) { return; } - final List locations = Objects.requireNonNull(this.beaconLocations.get(beaconId)); - if (locations.isEmpty()) { + final Optional maybeLast = this.beaconLocations.lastLocationOf(beaconId); + if (maybeLast.isEmpty()) { Log.w(TAG, "Can't navigate to a beacon that has no locations!"); return; } - final BeaconLocationReport lastLocation = locations.get(locations.size() - 1); + final BeaconLocationReport lastLocation = maybeLast.get(); Uri uri = Uri.parse(String.format(Locale.ROOT, "geo:%.7f,%.7f?q=%.7f,%.7f", lastLocation.getLatitude(), lastLocation.getLongitude(), lastLocation.getLatitude(), lastLocation.getLongitude())); Intent mapIntent = new Intent(Intent.ACTION_VIEW, uri); mapIntent.setPackage("com.google.android.apps.maps"); @@ -540,25 +618,27 @@ private void onImportFilePicked(Intent data) { LENGTH_LONG).show(); }); }) - .publish(storedBeacons -> /* - * Note: this is a bit of a fork-join: https://stackoverflow.com/questions/48015796/using-rxjava-to-fork-into-tasks-and-combine-results - * May be too much of an optimization, but I figured I might as well try out the extent - * of what you can do with these RXJava observables as far as multithreading goes... + * Fetching and parsing still run concurrently, but they are merged rather than + * zipped: zip completed as soon as the single-emission parse branch did, which + * cancelled every accessory after the first. See RxFlows#allThen for the full + * account. The two doOnNext handlers write to different maps, so their order + * relative to each other does not matter. */ - Observable.zip( - storedBeacons.flatMap(beacons -> - this.fetchLastReports(beacons.getOwnedBeacons().stream() - .collect(Collectors.toMap(b -> b.id, b -> b.content)), HOURS_TO_GO_BACK_24H) - ) - .doOnNext(this::addBeaconLocationsToCurrent), - storedBeacons.flatMap(beacons -> BeaconDataParser.parseAsync(BeaconCombinerUtil.combine(beacons))) - .doOnNext(this::addBeaconToCurrent), - Pair::create - ) - ) - .flatMapCompletable((__) -> this.updateBeaconGeocodings()) + .flatMapCompletable(storedBeacons -> RxFlows.allThen( + // Once, after every accessory has landed, rather than per accessory. + this.updateBeaconGeocodings(), + this.fetchLastReports(storedBeacons.getOwnedBeacons().stream() + .collect(Collectors.toMap(b -> b.id, b -> b.content)), HOURS_TO_GO_BACK_24H) + .doOnNext(this::addBeaconLocationsToCurrent), + BeaconDataParser.parseAsync(BeaconCombinerUtil.combine(storedBeacons)) + .doOnNext(this::addBeaconToCurrent) + )) .observeOn(AndroidSchedulers.mainThread()) + // An import is a first fetch too. Without this the periodic refresh stays disabled + // for the rest of the session whenever the app started with nothing stored, which + // is exactly the case where someone has just imported their first zip. + .doFinally(() -> this.initialFetchComplete = true) .subscribe(() -> { this.showLastDeviceLocations(); Log.i(TAG, "Finished visualising new location reports!"); @@ -660,14 +740,16 @@ public void onClickLocationHistory(View view) { .findFirst() .orElseThrow(() -> new RuntimeException("Click location history event was raised by a Beacon Device's card, but the beaconId could not be found for it!")); - var pos = this.map.getCameraPosition(); + IMapProvider.CameraPosition pos = this.mapProvider != null ? this.mapProvider.getCameraPosition() : null; Log.d(TAG, "Going to the history page for beaconId=" + beaconId); Intent viewHistoryIntent = new Intent(this, HistoryViewActivity.class); viewHistoryIntent.putExtra("beaconId", beaconId); - viewHistoryIntent.putExtra("lon", pos.target.longitude); - viewHistoryIntent.putExtra("lat", pos.target.latitude); - viewHistoryIntent.putExtra("zoom", pos.zoom); + if (pos != null) { + viewHistoryIntent.putExtra("lon", pos.getLongitude()); + viewHistoryIntent.putExtra("lat", pos.getLatitude()); + viewHistoryIntent.putExtra("zoom", pos.getZoom()); + } startActivity(viewHistoryIntent); } @@ -756,11 +838,10 @@ private void getAppleUser(Optional userAuth) { this.sendToLogin(); return; } - // else stay here & restore the account & get the user settings - var userSettings = userSettingsRepo.getUserSettings(); - - // Get Apple account - var asyncAppleService = PythonAuthService.restoreAccount(userAuth.get(), userSettings.getAnisetteServerUrl()) + // else stay here & restore the account. + // Note: FindMy 0.9.x embeds the anisette URL in the account JSON itself, + // so we no longer need to read userSettings.getAnisetteServerUrl() here. + var asyncAppleService = PythonAuthService.restoreAccount(userAuth.get()) .map(appleAccount -> { this.appleService = PythonAppleService.setup(appleAccount); return this.appleService; @@ -800,21 +881,53 @@ private void getAppleUser(Optional userAuth) { .flatMap(o -> this.updateBeaconGeocodings().andThen(Observable.just(o))) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) + // On termination rather than on a result. With no beacons stored - a first run, or + // before any import - this stream completes without ever emitting, so setting the flag + // in onNext left it false forever and the periodic refresh never ran again. + .doFinally(() -> this.initialFetchComplete = true) .subscribe(lastReports -> { - this.initialFetchComplete = true; //Toast.makeText(this.getApplicationContext(), "Yay, got last reports!", LENGTH_SHORT).show(); TagCardHelper.toggleRefreshLoadingAll(this.dynamicCardsForTag, false); this.showLastDeviceLocations(); Log.i(TAG, "Successfully retrieved latest reports!"); }, error -> { - this.initialFetchComplete = true; Log.e(TAG, "Error while restoring account and trying to get latest beacons", error); TagCardHelper.toggleRefreshLoadingAll(this.dynamicCardsForTag, false); + + if (isAccountRestoreFailure(error)) { + // Most likely cause: a session blob saved by FindMy 0.7.6 that 0.9.x cannot + // restore. Wipe the bad blob and route the user back to login with a hint. + Log.w(TAG, "Account restore failed; clearing saved auth and prompting re-login"); + handleAccountRestoreFailureOnUiThread(); + } //Toast.makeText(this.getApplicationContext(), "Error while trying to fetch data for beacons", LENGTH_SHORT).show(); // this error just happens every now and then. It's no big deal, we will retry automatically eventually... }); } + private static boolean isAccountRestoreFailure(Throwable t) { + for (Throwable cur = t; cur != null; cur = cur.getCause()) { + if (cur instanceof PythonAccountLoginException) { + return true; + } + } + return false; + } + + private void handleAccountRestoreFailureOnUiThread() { + var disposable = this.userAuthRepo.clearUser() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + Toast.makeText(this, R.string.relogin_required_after_upgrade, LENGTH_LONG).show(); + this.finish(); + this.sendToLogin(); + }, err -> { + Log.e(TAG, "Failed to clear stale auth blob during restore-failure recovery", err); + // Fall through silently — at worst the user has to log out manually. + }); + } + private synchronized void addBeaconToCurrent(final List newBeaconInformation) { newBeaconInformation.forEach(beacon -> { final String beaconId = beacon.getBeaconId(); @@ -826,30 +939,14 @@ private synchronized void addBeaconToCurrent(final List newBe } private synchronized void addBeaconLocationsToCurrent(final Map> newItems) { - for (var key: newItems.keySet()) { - if (this.beaconLocations.containsKey(key)) { - - var newMergedList = BeaconCombinerUtil.combineAndSort( - key, - Objects.requireNonNull(this.beaconLocations.get(key)), - Objects.requireNonNull(newItems.get(key)) - ); - - Log.d(TAG, String.format( - "Merged location history for beaconId=%s, which had %d items of location history, with %d new items of locationHistory to get a total of %d items of locationHistory", - key, - Objects.requireNonNull(this.beaconLocations.get(key)).size(), - Objects.requireNonNull(newItems.get(key)).size(), - newMergedList.size() - )); - - // we need to merge and re-sort... - this.beaconLocations.put(key, newMergedList); - } else { - Log.d(TAG, "Adding new location for beaconId=" + key); - this.beaconLocations.put(key, newItems.get(key)); - } - } + newItems.forEach((beaconId, reports) -> { + final int before = this.beaconLocations.sizeOf(beaconId); + final int after = this.beaconLocations.merge(beaconId, reports); + + Log.d(TAG, String.format( + "Location history for beaconId=%s: %d held + %d fetched = %d after de-duplication", + beaconId, before, reports.size(), after)); + }); } private Completable updateBeaconGeocodings() { @@ -864,19 +961,13 @@ private synchronized Completable updateBeaconGeocodingsSync() { for (BeaconData beaconData : this.beacons.values()) { final String beaconId = beaconData.getInfo().getBeaconId(); - if (!this.beaconLocations.containsKey(beaconId)) { + final Optional maybeLast = this.beaconLocations.lastLocationOf(beaconId); + if (maybeLast.isEmpty()) { Log.d(TAG, "Can't update geocoding for beacon=" + beaconId + " because it contained no locations"); continue; } - List locations = Objects.requireNonNull(this.beaconLocations.get(beaconId)); - - if (locations.isEmpty()) { - Log.d(TAG, "Did not reverse geocode the last location for beaconId=" + beaconId + " because it had no known locations"); - continue; - } - - BeaconLocationReport lastLocation = locations.get(locations.size() - 1); + BeaconLocationReport lastLocation = maybeLast.get(); final Double lastLat = Optional.ofNullable(beaconData.getLastGeocodingLocation()).map(pos -> pos.latitude).orElse(null); final Double lastLon = Optional.ofNullable(beaconData.getLastGeocodingLocation()).map(pos -> pos.longitude).orElse(null); if (lastLat != null && lastLat == lastLocation.getLatitude() && lastLon != null && lastLon == lastLocation.getLongitude()) { @@ -913,45 +1004,41 @@ private synchronized void showLastDeviceLocations() { BeaconInformation beacon = beaconData.getInfo(); final String beaconId = beacon.getBeaconId(); - if (!this.beaconLocations.containsKey(beaconId)) { - Log.d(TAG, "No location was currently know for beacon with id " + beaconId + ". It is being skipped."); - continue; - } - - List locations = Objects.requireNonNull(this.beaconLocations.get(beaconId)); - - if (locations.isEmpty()) { - Log.d(TAG, "Did not get any location reports for beacon device with id " + beacon.getBeaconId() + ". This device will not be shown in the UI."); + final Optional maybeLast = this.beaconLocations.lastLocationOf(beaconId); + if (maybeLast.isEmpty()) { + Log.d(TAG, "No location is currently known for beaconId=" + beaconId + ", so it cannot be drawn. Skipping."); continue; } - BeaconLocationReport lastLocation = locations.get(locations.size() - 1); - this.showBeaconOnMap(beacon, lastLocation); + this.showBeaconOnMap(beacon, maybeLast.get()); } this.updateBeaconCards(); } private synchronized void showBeaconOnMap(final BeaconInformation beacon, final BeaconLocationReport lastLocation) { final String beaconId = beacon.getBeaconId(); - final LatLng locationTag = new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet, cannot show beacon"); + return; + } if (this.currentMarkers.containsKey(beaconId)) { - // remove the old marker - Log.d(TAG, "Going to move the existing marker for beaconId=" + beaconId); - var marker = Objects.requireNonNull(this.currentMarkers.get(beaconId)); - marker.setPosition(locationTag); - return; + // remove the old marker and add a new one + Log.d(TAG, "Going to replace the existing marker for beaconId=" + beaconId); + this.mapProvider.removeMarker(beaconId); } Log.d(TAG, "Going to add new marker for beaconId=" + beaconId); - BitmapDescriptor icon; + // 创建自定义标记图标 + android.graphics.Bitmap iconBitmap; if (beacon.isEmojiFilled()) { - icon = VectorImageGeneratorUtil.makeMarker( + iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), beacon.getEmoji(), getColor(R.color.md_theme_background)); } else { - icon = VectorImageGeneratorUtil.makeMarker( + iconBitmap = VectorImageGeneratorUtil.makeMarker( getResources(), R.drawable.apple, getColor(R.color.md_theme_background), @@ -959,24 +1046,35 @@ private synchronized void showBeaconOnMap(final BeaconInformation beacon, final ); } - var markerOptions = new MarkerOptions() - .position(locationTag) - //.title(markerTitle) - .icon(icon); - Marker marker = this.map.addMarker(markerOptions); + // 使用抽象接口添加标记 + MapMarker marker = MapMarker.builder() + .id(beaconId) + .latitude(lastLocation.getLatitude()) + .longitude(lastLocation.getLongitude()) + .icon(iconBitmap) + // Every refresh removes and re-adds the markers, so the selected one has to be + // built raised. Setting it only on selection would let the next refresh drop + // it back underneath the pile without the user touching anything. + .zIndex(this.markerFocus.zIndexFor(beaconId)) + .build(); + + String markerId = this.mapProvider.addMarker(marker); + this.currentMarkers.put(beaconId, markerId); // 存储markerId而不是Marker对象 - this.currentMarkers.put(beaconId, marker); if (this.currentMarkers.size() == 1) { // for the first marker, navigate to it smoothly on the map! - // (we choose the first added marker here because it is the - // one that will become visible in the UI tag list at the - // bottom of the screen) this.goToBeaconOnMap(beaconId, CAMERA_ON_MAP_INITIAL_ZOOM); } } private void setupTagScrollArea() { HorizontalScrollView scrollContainer = this.findViewById(R.id.tags_scrollable_area); + + // The cards size themselves to their content now, so nothing absorbs the navigation bar + // any more. This used to be hidden by the area's fixed 240dp height, which held about + // 70dp of slack below the card. + WindowPaddingUtil.insertUIBottomPadding(scrollContainer); + this.tagListSwiperHelper = new TagListSwiperHelper( scrollContainer, this.dynamicCardsForTag, @@ -991,43 +1089,46 @@ private void goToBeaconOnMap(final String beaconId) { private void goToBeaconOnMap(final String beaconId, Float zoom) { try { - Marker marker = Objects.requireNonNull(this.currentMarkers.get(beaconId)); - this.bringMarkerToTop(marker); + if (this.mapProvider == null) { + Log.w(TAG, "Map provider is not ready yet"); + return; + } + + // 从beaconLocations获取位置信息 + final Optional maybeLast = this.beaconLocations.lastLocationOf(beaconId); + if (maybeLast.isEmpty()) { + Log.w(TAG, "No locations found for beaconId=" + beaconId); + return; + } - var pos = marker.getPosition(); + BeaconLocationReport lastLocation = maybeLast.get(); + double lat = lastLocation.getLatitude(); + double lon = lastLocation.getLongitude(); Log.d(TAG, "Animating camera to position of marker for beaconId=" + beaconId + " after it was selected in the bottom tag list..."); + this.markerFocus.focus(beaconId); + if (zoom != null) { - this.map.animateCamera(CameraUpdateFactory.newLatLngZoom(pos, zoom)); + this.mapProvider.animateCamera(lat, lon, zoom, null); } else { - this.map.animateCamera(CameraUpdateFactory.newLatLng(pos)); + // 使用当前缩放级别 + IMapProvider.CameraPosition currentPos = this.mapProvider.getCameraPosition(); + float currentZoom = currentPos != null ? currentPos.getZoom() : CAMERA_ON_MAP_INITIAL_ZOOM; + this.mapProvider.animateCamera(lat, lon, currentZoom, null); } } catch (Exception e) { Log.e(TAG, "Failure when trying to navigate to marker on map on lock into card for beaconId=" + beaconId, e); } } - private void bringMarkerToTop(Marker marker) { - if (this.lastFocusedMarker == marker) { - // do nothing - return; - } - - if (this.lastFocusedMarker != null) { - this.lastFocusedMarker.setZIndex(MARKER_ZINDEX_DEFAULT); - } - marker.setZIndex(MARKER_ZINDEX_TOP); - this.lastFocusedMarker = marker; - } - private synchronized void updateBeaconCards() { HorizontalScrollView scrollContainer = this.findViewById(R.id.tags_scrollable_area); LinearLayout cardsContainer = this.findViewById(R.id.tags_scroll_container); // remove all beacons that had cards that are now gone for (var beaconId : this.dynamicCardsForTag.keySet()) { - if (!this.beacons.containsKey(beaconId) || !this.beaconLocations.containsKey(beaconId)) { + if (!this.beacons.containsKey(beaconId) || !this.beaconLocations.isDrawable(beaconId)) { Log.i(TAG, "Cleaning up view for beaconId=" + beaconId + " which did not have any locations associated with itself anymore"); View view = this.dynamicCardsForTag.get(beaconId); cardsContainer.removeView(view); @@ -1040,16 +1141,12 @@ private synchronized void updateBeaconCards() { for (final BeaconData beaconData : this.beacons.values()) { final BeaconInformation beacon = beaconData.getInfo(); final String beaconId = beacon.getBeaconId(); - if (!this.beaconLocations.containsKey(beaconId)) { + final Optional maybeLast = this.beaconLocations.lastLocationOf(beaconId); + if (maybeLast.isEmpty()) { Log.w(TAG, "Found a beacon (" + beaconId + ") without locations! We can't draw such a beacon. Skipping..."); continue; } - var locations = Objects.requireNonNull(this.beaconLocations.get(beaconId)); - if (locations.isEmpty()) { - Log.w(TAG, "Fond a beacon (" + beaconId + ") with no location history items! We can't draw such a beacon. Skipping..."); - continue; - } - final BeaconLocationReport lastLocation = locations.get(locations.size() - 1); + final BeaconLocationReport lastLocation = maybeLast.get(); final List
locationInfo = beaconData.getGeocoding(); FrameLayout v; @@ -1068,6 +1165,13 @@ private synchronized void updateBeaconCards() { var params = v.getLayoutParams(); params.width = this.windowWidth != 0 ? (this.windowWidth - 80) : (this.getWindow().getDecorView().getWidth() - 80); + // Cards size themselves to their own content, so a two-line address made one card + // taller than its neighbours. Inflating with a null root drops the layout's own + // height, and the container hands out WRAP_CONTENT by default, so it has to be set + // here: MATCH_PARENT inside a wrap_content horizontal LinearLayout triggers + // forceUniformHeight, which measures every card to the height of the tallest. + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + v.setLayoutParams(params); // the title @@ -1109,7 +1213,12 @@ private synchronized void updateBeaconCards() { ImageButton navigationButton = this.findViewById(R.id.button_navigate_to); if (this.dynamicCardsForTag.isEmpty()) { - scrollContainer.setVisibility(GONE); // HIDE PARENT CONTAINER (FOR NOW) + // INVISIBLE, not GONE. buttons_bottom_right is positioned with layout_above + // against this view, and RelativeLayout ignores layout_above when the anchor is + // GONE - the buttons would then fall back to the top of the screen and render + // under the status bar. Left INVISIBLE it still occupies its (zero, since it has + // no cards) height at the bottom, so the anchor keeps resolving. + scrollContainer.setVisibility(INVISIBLE); navigationButton.setVisibility(GONE); } else { scrollContainer.setVisibility(VISIBLE); // UNHIDE PARENT CONTAINER @@ -1122,6 +1231,15 @@ private void sendToLogin() { startActivity(intent); } + private IMapProvider.MapStyle getPreferredMapStyle() { + if (this.userSettings == null || this.userSettings.getUseDarkTheme() == null) { + return IMapProvider.MapStyle.FOLLOW_SYSTEM; + } + return this.userSettings.getUseDarkTheme() + ? IMapProvider.MapStyle.DARK + : IMapProvider.MapStyle.LIGHT; + } + private void fetchAndUpdateCurrentBeacons() { var beacons = this.beacons.values().stream() .collect(Collectors.toMap(b -> b.getInfo().getBeaconId(), b -> b.getInfo().getOwnedBeaconPlistRaw())); @@ -1145,29 +1263,130 @@ private void fetchAndUpdateCurrentBeacons() { } private Observable>> fetchLastReports(final Map beaconIdToPlist) { + // Captured once and reused below: recording the finish time instead would leave a gap + // in history the width of the fetch, which for an unaligned tag is minutes. final long now = System.currentTimeMillis(); - - final int hoursToGoBack = (int) Math.min( - Math.ceil(((double)now - (double)this.last24HHistoryFetchAt)/(double)ONE_HOUR_IN_MS), - HOURS_TO_GO_BACK_24H - ); + final int hoursToGoBack = this.refreshPolicy.hoursToGoBack(now); Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(beaconIdToPlist, hoursToGoBack) - .doOnNext(reports -> this.last24HHistoryFetchAt = now) // on success, update this time. - .flatMap(this.beaconRepo::storeToLocationCache); + return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) + .doOnSubscribe(__ -> this.markFetchStarted()) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) + .doOnNext(reports -> this.refreshPolicy.markFetched(now)) // on success, update this time. + .doFinally(this::markFetchFinished); } private Observable>> fetchLastReports(final Map beaconIdToPlist, final int hoursToGoBack) { Log.d(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(beaconIdToPlist, hoursToGoBack) - .flatMap(this.beaconRepo::storeToLocationCache); + return this.beaconRepo.toAccessoryRequests(beaconIdToPlist) + .doOnSubscribe(__ -> this.markFetchStarted()) + .flatMap(requests -> this.fetchOneAccessoryAtATime(requests, hoursToGoBack)) + .doFinally(this::markFetchFinished); + } + + /** + * Fetches each accessory in its own call into Python, storing the result before moving on. + *
+ * Handing Python the whole list meant it returned a single dict at the very end, so the + * updated key alignment for every accessory was written in one go. A tag with no alignment + * record takes minutes to resolve, and quitting part-way through discarded the work for + * all of them - including the ones that had already finished - so the next launch searched + * the same tens of thousands of key indices again. One call per accessory means each one + * is persisted as soon as it resolves. + *
+ * It also lets the UI update per tag instead of all at once, and keeps a single failure + * from taking the rest of the batch with it. + *
+ * Sequential on purpose: FindMy.py's synchronous account drives one asyncio event loop, + * and calls into Python are serialised anyway (see PythonAppleService). + *
+ * The sequencing itself lives in {@link RxFlows#oneAtATime} so it can be tested without + * an Activity - see {@code RxFlowsTest}. + */ + private Observable>> fetchOneAccessoryAtATime( + final List requests, final int hoursToGoBack) { + + return RxFlows.oneAtATime( + requests, + request -> this.appleService.getLastReports(List.of(request), hoursToGoBack) + .flatMap(this.beaconRepo::storeFetchResult), + this::setLongFetchProgress, + (request, error) -> Log.e(TAG, + "Failed to fetch reports for beaconId=" + request.getBeaconId() + + "; continuing with the remaining accessories", error)); } private Observable>> fetchLastReportsFor(final String beaconId, final String pList, final int hoursToGoBack) { Log.i(TAG, "Preparing to fetch location reports for the last " + hoursToGoBack + " hours!"); - return this.appleService.getLastReports(Map.of(beaconId, pList), hoursToGoBack) - .flatMap(this.beaconRepo::storeToLocationCache); + return this.beaconRepo.toAccessoryRequests(Map.of(beaconId, pList)) + .doOnSubscribe(__ -> this.markFetchStarted()) + .flatMap(requests -> this.appleService.getLastReports(requests, hoursToGoBack)) + .flatMap(this.beaconRepo::storeFetchResult) + .doFinally(this::markFetchFinished); + } + + /** + * Starts the clock on the "still working" banner. + *
+ * A tag whose export carried no KeyAlignmentRecord starts at index 0 from its pairing + * date, so its first fetch searches the tag's whole life - tens of thousands of key + * indices, at roughly 290 per request. That is minutes of sequential requests during + * which nothing changes on screen, and it is indistinguishable from a hang. + *
+ * It also matters that the user does not walk away: Python returns the updated + * alignment for every accessory in one dict at the end of the batch, so quitting + * part-way through discards the work for all of them and the next launch starts over. + */ + private void markFetchStarted() { + this.longFetchBannerHandler.post(() -> { + if (this.bannerState.fetchStarted()) { + this.longFetchBannerHandler.postDelayed( + this.showLongFetchBanner, SHOW_LONG_FETCH_BANNER_AFTER_MS); + } + }); + } + + private void markFetchFinished() { + this.longFetchBannerHandler.post(() -> { + if (!this.bannerState.fetchFinished()) { + return; + } + this.longFetchBannerHandler.removeCallbacks(this.showLongFetchBanner); + this.setLongFetchBannerVisible(false); + }); + } + + /** Records how far through the batch we are, so the banner can say so. */ + private void setLongFetchProgress(int done, int total) { + this.longFetchBannerHandler.post(() -> { + this.bannerState.setProgress(done, total); + + TextView banner = this.findViewById(R.id.long_fetch_banner); + if (banner != null && banner.getVisibility() == VISIBLE) { + banner.setText(this.longFetchBannerText()); + } + }); + } + + private String longFetchBannerText() { + if (!this.bannerState.hasCount()) { + return this.getString(R.string.resolving_tags_banner); + } + return this.getString( + R.string.resolving_tags_banner_progress, + this.bannerState.displayedPosition(), + this.bannerState.total()); + } + + private void setLongFetchBannerVisible(boolean visible) { + TextView banner = this.findViewById(R.id.long_fetch_banner); + if (banner == null) { + return; + } + if (visible) { + banner.setText(this.longFetchBannerText()); + } + banner.setVisibility(visible ? VISIBLE : GONE); } private boolean isAppleServiceInitialised() { @@ -1179,7 +1398,10 @@ private void enableMyLocation(boolean navigateToMyLocation) { if (ContextCompat.checkSelfPermission(this, ACCESS_FINE_LOCATION) == PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED) { Log.i(TAG, "Enabling 'my location' related UI features..."); - this.map.setMyLocationEnabled(true); + // 注意:抽象接口可能不支持setMyLocationEnabled,这里保留向后兼容 + if (this.map != null) { + this.map.setMyLocationEnabled(true); + } // This UI button is only available if the user enables own location permissions. ImageButton button = findViewById(R.id.button_my_location); @@ -1230,15 +1452,15 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis } @Override - public boolean onMarkerClick(@NonNull Marker marker) { - this.bringMarkerToTop(marker); - - Optional beaconIdForMarker = this.currentMarkers.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(marker)) - .map(Map.Entry::getKey) - .findFirst(); + public boolean onMarkerClick(String markerId) { + // 查找对应的beaconId + Optional beaconIdForMarker = Optional.ofNullable(markerId); if (beaconIdForMarker.isPresent()) { + // Tapping a marker raises it as well, not only selecting its card. Without this, + // tapping the one visible marker in a pile scrolls to its card but leaves the + // marker underneath whichever one is drawn on top - so the tap looks ignored. + this.markerFocus.focus(beaconIdForMarker.get()); this.tagListSwiperHelper.navigateToCard(beaconIdForMarker.get()); } else { Log.w(TAG, "Clicked on a marker that could not be associated back to any beaconId!"); @@ -1285,4 +1507,4 @@ public BeaconData(@lombok.NonNull BeaconInformation info, @lombok.NonNull List this.handleStartImport()); + + findViewById(R.id.my_devices_empty_wiki_link) + .setOnClickListener(v -> this.openExportGuide()); + this.getOnBackPressedDispatcher().addCallback(new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { @@ -109,6 +131,7 @@ private void refreshListOnItemRemoved(final String beaconId) { final int index = removedIndex.getAsInt(); this.beaconInfo.remove(index); deviceListAdaptor.notifyItemRangeRemoved(index, 1); + this.updateEmptyState(); } } @@ -146,6 +169,7 @@ private void fetchDeviceInfoAndRender() { this.beaconInfo.addAll(beaconsAndLocations.first); this.locations.putAll(beaconsAndLocations.second); deviceListAdaptor.notifyItemRangeInserted(0, this.beaconInfo.size()); + this.updateEmptyState(); }, error -> Log.e(TAG, "Failure retrieving beacons and latest stored locations for beacon")); } @@ -154,4 +178,96 @@ private void onDeviceClicked(final BeaconInformation clickedDevice) { deviceInfoIntent.putExtra("beaconId", clickedDevice.getBeaconId()); deviceInfoActivityLauncher.launch(deviceInfoIntent); } + + /** + * Long press on a row offers to remove it, saving a trip through the device's detail page. + *
+ * Anchored to the pressed row rather than shown as a dialog, so it is obvious which device + * is about to be acted on - the confirmation itself does not name the device. + */ + private void onDeviceLongPressed(final View anchor, final BeaconInformation device) { + PopupMenu menu = new PopupMenu(this, anchor); + menu.getMenuInflater().inflate(R.menu.device_list_item_menu, menu.getMenu()); + + menu.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.action_remove_device) { + this.confirmRemoveDevice(device); + return true; + } + return false; + }); + + menu.show(); + } + + private void confirmRemoveDevice(final BeaconInformation device) { + new MaterialAlertDialogBuilder(this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.remove_device) + .setIcon(R.drawable.delete_24px) + .setMessage(R.string.are_you_sure_you_want_to_remove_this_device_once_removed_it_will_need_to_be_reimported_to_get_it_back) + .setPositiveButton(R.string.confirm, (dialog, which) -> this.removeDevice(device)) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void removeDevice(final BeaconInformation device) { + final String beaconId = device.getBeaconId(); + + // Same call DeviceInfoActivity makes: rows are hidden rather than deleted, so the + // location history survives a re-import. + var async = this.beaconRepo.markBeaconAsRemoved(beaconId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> this.refreshListOnItemRemoved(beaconId), + error -> { + Log.e(TAG, "Failure marking beacon as removed!", error); + Toast.makeText( + this.getApplicationContext(), + R.string.error_occurred_while_removing_the_device, + LENGTH_LONG).show(); + }); + } + + /** + * The zip has to be produced on a Mac, so a user who has nothing imported cannot get one + * from inside the app. Without this the empty state tells them what they need and gives + * them no way to find out how to get it. + */ + private void openExportGuide() { + var properties = PropertiesUtil.getProperties(this.getAssets(), "app.properties"); + if (properties == null) { + Log.w(TAG, "Could not read app.properties; no export guide link to open"); + return; + } + + final String url = properties.getProperty("exportWikiPage"); + if (url == null || url.isBlank()) { + Log.w(TAG, "No exportWikiPage configured in app.properties"); + return; + } + + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + if (intent.resolveActivity(getPackageManager()) != null) { + this.startActivity(intent); + } + } + + private void handleStartImport() { + // Importing is driven from MapsActivity, which owns the file picker and the code that + // fetches locations for whatever comes back. Hand the request back rather than + // duplicating any of that here. + Intent data = new Intent(); + data.putExtra("isDeviceListChanged", this.devicesListChanged); + data.putExtra("startImport", true); + setResult(RESULT_OK, data); + this.finish(); + } + + /** Swaps the list for the empty state, or back, after anything changes the contents. */ + private void updateEmptyState() { + final boolean isEmpty = this.beaconInfo.isEmpty(); + + findViewById(R.id.my_devices_empty_state).setVisibility(isEmpty ? VISIBLE : GONE); + findViewById(R.id.my_devices_list).setVisibility(isEmpty ? GONE : VISIBLE); + } } \ No newline at end of file diff --git a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java index 9488a3ff..a064322d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/OpenAirTagApplication.java @@ -17,9 +17,84 @@ public class OpenAirTagApplication extends PyApplication { public void onCreate() { super.onCreate(); + // 高德地图SDK隐私合规初始化 + // 必须在调用任何SDK接口之前调用 + this.initAMapPrivacyCompliance(); + this.setupTheme(); } + /** + * Hands the user's own AMap API key to the SDK. + *
+ * Reflection is used throughout so the app still runs when the AMap SDK is absent, + * which is the normal case for anyone using Google Maps. + */ + private void applyUserSuppliedAMapKey() { + try { + var settings = new UserSettingsRepository( + UserSettingsDataStore.getInstance(this.getApplicationContext()) + ).getUserSettings(); + + if (!settings.hasAmapApiKey()) { + // Expected unless the user has chosen AMap and supplied a key. + Log.d(TAG, "No user-supplied AMap API key; skipping AMap initialisation"); + return; + } + + Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); + mapsInitializerClass + .getMethod("setApiKey", String.class) + .invoke(null, settings.getAmapApiKey()); + + Log.i(TAG, "Applied user-supplied AMap API key"); + } catch (ClassNotFoundException e) { + Log.d(TAG, "AMap SDK not present; skipping"); + } catch (Exception e) { + Log.e(TAG, "Failed to apply the user-supplied AMap API key", e); + } + } + + /** + * 高德地图隐私合规设置 + * 根据《个人信息保护法》要求,必须在调用SDK任何接口之前进行隐私合规配置 + * 参考文档:https://lbs.amap.com/api/android-sdk/guide/create-map/dev-attention + */ + private void initAMapPrivacyCompliance() { + try { + // The app ships no AMap key. Keys are issued per developer account and bound to + // a package name and signing fingerprint, and AMap's terms expect the key holder + // to be the app's operator - so each user supplies their own in Settings, the + // same way the Anisette server URL works. Apply it before any SDK call. + this.applyUserSuppliedAMapKey(); + + // 使用反射加载高德地图SDK,避免编译时依赖 + Class mapsInitializerClass = Class.forName("com.amap.api.maps.MapsInitializer"); + + // 更新隐私合规弹窗状态 + // updatePrivacyShow(Context context, boolean isContains, boolean isShow) + // isContains: 隐私权政策是否包含高德开平隐私权政策 + // isShow: 隐私权政策是否弹窗展示告知用户 + java.lang.reflect.Method updatePrivacyShowMethod = mapsInitializerClass.getMethod( + "updatePrivacyShow", android.content.Context.class, boolean.class, boolean.class); + updatePrivacyShowMethod.invoke(null, this, true, true); + + // 更新用户同意隐私政策状态 + // updatePrivacyAgree(Context context, boolean isAgree) + // isAgree: 隐私权政策是否取得用户同意 + java.lang.reflect.Method updatePrivacyAgreeMethod = mapsInitializerClass.getMethod( + "updatePrivacyAgree", android.content.Context.class, boolean.class); + updatePrivacyAgreeMethod.invoke(null, this, true); + + Log.i(TAG, "AMap privacy compliance initialized successfully"); + } catch (ClassNotFoundException e) { + // 高德地图SDK未集成,这是正常情况 + Log.d(TAG, "AMap SDK not found, privacy compliance initialization skipped"); + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AMap privacy compliance", e); + } + } + public void setupTheme() { final int currentNightMode = this.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; @@ -29,6 +104,10 @@ public void setupTheme() { var userSettings = userSettingsRepo.getUserSettings(); final Boolean useDarkTheme = userSettings.getUseDarkTheme(); + if (useDarkTheme == null) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + return; + } if (currentNightMode == Configuration.UI_MODE_NIGHT_NO && useDarkTheme == Boolean.TRUE) { Log.i(TAG, "Updating to app dark theme choice"); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java index 4c98950d..4b4220c5 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/SettingsActivity.java @@ -33,6 +33,7 @@ import com.google.android.material.progressindicator.IndeterminateDrawable; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.textfield.MaterialAutoCompleteTextView; +import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import java.util.ArrayList; @@ -59,9 +60,12 @@ import dev.wander.android.opentagviewer.service.web.GithubRawUtilityFilesService; import dev.wander.android.opentagviewer.service.web.sidestore.AnisetteServerSuggestion; import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil; +import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog; import dev.wander.android.opentagviewer.ui.extensions.AppAutoCompleteTextView; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.LocaleConfigUtil; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.util.android.SigningInfoUtil; import dev.wander.android.opentagviewer.util.validate.AnisetteUrlValidatorUtil; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import lombok.Data; @@ -94,6 +98,7 @@ public class SettingsActivity extends AppCompatActivity { private String editorSelectedLocateId = null; private String initialAnisetteUrl = null; + private boolean mapProviderChanged = false; @Override @@ -132,6 +137,8 @@ protected void onCreate(Bundle savedInstanceState) { this.binding.setCurrentLanguage(Optional.ofNullable(this.currentSettings.getLanguage()).map(this::getPrettyLanguageName).orElse(this.getString(R.string.use_system_default))); this.binding.setOnClickAnisetteServerUrl(this::onClickEditAnisetteServerUrl); this.binding.setCurrentAnisetteServerUrl(this.currentSettings.getAnisetteServerUrl()); + this.binding.setOnClickMapProvider(this::onClickEditMapProvider); + this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); this.binding.setIsDebugDataEnabled(Optional.ofNullable(this.currentSettings.getEnableDebugData()).orElse(false)); if (this.getSupportActionBar() != null) { @@ -158,9 +165,19 @@ protected void onCreate(Bundle savedInstanceState) { } private void handleEndActivity() { + if (this.mapProviderChanged) { + Intent data = new Intent(); + data.putExtra("mapProviderChanged", true); + setResult(RESULT_OK, data); + } this.finish(); } + @Override + public void onBackPressed() { + this.handleEndActivity(); + } + private void onDebugDataEnabledChange(CompoundButton buttonView, boolean isChecked) { final Boolean oldChoice = this.currentSettings.getEnableDebugData(); if (oldChoice == null || oldChoice != isChecked) { @@ -203,10 +220,108 @@ private void onClickEditTheme() { builder.show(); } + private String getCurrentMapProviderUiString() { + String provider = this.currentSettings.getMapProvider(); + if (provider == null || provider.isEmpty() || "google".equals(provider)) { + return this.getString(R.string.map_provider_google); + } else if ("amap".equals(provider)) { + return this.getString(R.string.map_provider_amap); + } + return this.getString(R.string.map_provider_google); + } + + private void onClickEditMapProvider() { + List providerChoices = new ArrayList<>(); + providerChoices.add(this.getString(R.string.map_provider_google)); + providerChoices.add(this.getString(R.string.map_provider_amap)); + + String currentProvider = this.currentSettings.getMapProvider(); + int currentOption = 0; // 默认Google Maps + if ("amap".equals(currentProvider)) { + currentOption = 1; + } + + var builder = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.map_provider) + .setPositiveButton(R.string.accept, (dialog, which) -> { + Log.d(TAG, "Selected new map provider option!"); + + int checkedItemPosition = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + + if (checkedItemPosition != AdapterView.INVALID_POSITION) { + String selectedProvider = checkedItemPosition == 0 ? "google" : "amap"; + Log.d(TAG, "Selected map provider: " + selectedProvider); + + // AMap ships with no key: they are issued per developer account and + // bound to a package name and signing fingerprint, so each user + // brings their own. Selecting it without one would leave a blank map + // and no explanation, so gate the change on having a key and take + // the user straight to entering one. + if ("amap".equals(selectedProvider) && !this.currentSettings.hasAmapApiKey()) { + Toast.makeText(this, R.string.amap_key_required, Toast.LENGTH_LONG).show(); + this.onClickEditAmapApiKey(selectedProvider); + return; + } + + this.updateMapProvider(selectedProvider); + } + }) + .setNegativeButton(R.string.cancel, null) + .setSingleChoiceItems(providerChoices.toArray(new CharSequence[0]), currentOption, null); + + builder.show(); + } + + /** + * Prompt for the user's own AMap API key. + * + * @param providerToApplyOnSuccess if non-null, the provider to switch to once a key has + * been supplied. Lets the picker route the user here and + * have their selection complete afterwards, rather than + * silently dropping it. + */ + private void onClickEditAmapApiKey(final String providerToApplyOnSuccess) { + AmapApiKeyDialog.show(this, this.currentSettings.getAmapApiKey(), enteredKey -> { + this.currentSettings.setAmapApiKey(enteredKey); + this.saveSettings(); + + if (enteredKey == null) { + // Without a key AMap cannot render anything, so fall back rather than + // leaving a provider selected that will only ever show a blank map. + Toast.makeText(this, R.string.amap_key_cleared, Toast.LENGTH_SHORT).show(); + if ("amap".equals(this.currentSettings.getMapProvider())) { + this.updateMapProvider("google"); + } + return; + } + + Toast.makeText(this, R.string.amap_key_saved, Toast.LENGTH_SHORT).show(); + if (providerToApplyOnSuccess != null) { + this.updateMapProvider(providerToApplyOnSuccess); + } + }); + } + + private void updateMapProvider(String provider) { + final String currentProvider = this.currentSettings.getMapProvider(); + this.mapProviderChanged = this.mapProviderChanged || !java.util.Objects.equals(currentProvider, provider); + this.currentSettings.setMapProvider(provider); + this.binding.setCurrentMapProvider(this.getCurrentMapProviderUiString()); + this.saveSettings(); + Log.i(TAG, "Updated map provider to: " + provider); + } + private void onClickEditLanguage() { View view = inflate(this, R.layout.language_input_dialog, null); - final String currentLocale = Locale.getDefault().getLanguage(); + final String currentLocale = Optional.ofNullable(this.currentSettings.getLanguage()) + .orElseGet(() -> { + String appLocales = AppCompatDelegate.getApplicationLocales().toLanguageTags(); + if (appLocales != null && !appLocales.isBlank()) { + return appLocales.split(",")[0]; + } + return Locale.getDefault().toLanguageTag(); + }); var availableLocales = LocaleConfigUtil.getAvailableLocales(this.getResources()) .toArray(new String[0]); @@ -220,7 +335,8 @@ private void onClickEditLanguage() { .sorted().toArray(String[]::new)); mappedLocales.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(currentLocale)) + .filter(kvp -> kvp.getValue().equalsIgnoreCase(currentLocale) + || currentLocale.toLowerCase(Locale.ROOT).startsWith(kvp.getValue().toLowerCase(Locale.ROOT) + "-")) .findFirst() .map(Map.Entry::getKey) .ifPresent(option -> languageDropdown.setText(option, false)); @@ -350,7 +466,14 @@ private void handleAnisetteUrlChangeSave(final String validNewAnisetteUrl) { var finalUrl = Optional.ofNullable(this.currentSettings.getAnisetteServerUrl()); if (!originalUrl.equals(finalUrl)) { - // we need to force a re-login, unfortunately + // A re-login is genuinely required, not just a limitation of how the account + // is serialized. Anisette supplies a machine identity (X-Apple-I-MD-M and + // friends) derived from that server's own ADI provisioning, and Apple binds + // the session to it, so a session established via one server is not valid + // when presented with another server's identity. Rewriting the stored + // provider would keep the app running but leave it failing auth against + // Apple, which is worse than an honest re-login. The dialog warns about this + // up front (see anisette_url_change_warning). this.performLogout(); } } @@ -418,6 +541,11 @@ private void fillInUIAuthInfo(UserAuthData userAuthData) { LinearLayout loginDataContainer = this.findViewById(R.id.login_info_container); loginDataContainer.setVisibility(VISIBLE); + // Swap the loading skeleton for the real thing. Both are sized alike, so this does not + // move anything below it. + this.findViewById(R.id.login_details_placeholder).setVisibility(GONE); + this.findViewById(R.id.login_details).setVisibility(VISIBLE); + TextView firstnameLastnameText = this.findViewById(R.id.firstame_lastname_settings_block); final String userFirstNameLastName = userAuthData.getAccount().getInfo().getFirstName() + " " + userAuthData.getAccount().getInfo().getLastName(); firstnameLastnameText.setText(userFirstNameLastName); @@ -471,7 +599,7 @@ private void saveSettings() { private String getPrettyLanguageName(final String languageId) { var res = this.getResources(); return res.getString(res.getIdentifier( - "lang_" + languageId, + LocaleConfigUtil.toLocaleLabelResourceName(languageId), "string", this.getPackageName())); } @@ -510,4 +638,4 @@ public void setButtonStage(boolean successStage) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java index 258859a1..9219732b 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/datastore/UserSettingsDataStore.java @@ -21,6 +21,8 @@ public final class UserSettingsDataStore { public static final Preferences.Key USE_DARK_THEME = PreferencesKeys.booleanKey("use_dark_theme"); public static final Preferences.Key ENABLE_DEBUG_DATA = PreferencesKeys.booleanKey("enable_debug_data"); + public static final Preferences.Key MAP_PROVIDER = PreferencesKeys.stringKey("map_provider"); + public static final Preferences.Key AMAP_API_KEY = PreferencesKeys.stringKey("amap_api_key"); public static RxDataStore getInstance(Context context) { if (PREFERENCES_DATA_STORE == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java index ff0fabd0..a401e7e4 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/BeaconRepository.java @@ -2,6 +2,7 @@ import android.util.Log; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -20,6 +21,10 @@ import dev.wander.android.opentagviewer.db.room.entity.OwnedBeacon; import dev.wander.android.opentagviewer.db.room.entity.UserBeaconOptions; import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; +import dev.wander.android.opentagviewer.python.AccessoryRequest; +import dev.wander.android.opentagviewer.python.ChaquopyPlistToAccessoryJsonConverter; +import dev.wander.android.opentagviewer.python.FetchResult; +import dev.wander.android.opentagviewer.python.PlistToAccessoryJsonConverter; import dev.wander.android.opentagviewer.util.BeaconLocationReportHasher; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Observable; @@ -29,9 +34,19 @@ public class BeaconRepository { private final static String TAG = BeaconRepository.class.getSimpleName(); private final OpenTagViewerDatabase db; + private final PlistToAccessoryJsonConverter accessoryJsonConverter; public BeaconRepository(OpenTagViewerDatabase db) { + this(db, new ChaquopyPlistToAccessoryJsonConverter()); + } + + /** + * Injectable converter, so the lazy accessory_json backfill can be tested without a + * running Python runtime. + */ + public BeaconRepository(OpenTagViewerDatabase db, PlistToAccessoryJsonConverter accessoryJsonConverter) { this.db = db; + this.accessoryJsonConverter = accessoryJsonConverter; } /** @@ -119,6 +134,85 @@ public Observable getById(final String beaconId) { }).subscribeOn(Schedulers.io()); } + /** + * Build the FindMy 0.9.x fetch input for the given beacons. For each beacon we use + * the persisted {@code accessory_json} if present, otherwise lazily backfill it + * from the legacy XML plist via {@code main.py:convertPlistToJson} (and persist + * the result so the next call is cheap). + * + * Beacons whose plist cannot be converted are dropped — passing them to Python + * would just throw inside FindMyAccessory.from_json. The caller will see a + * shorter fetch result than it asked for, which is preferable to taking down + * the whole batch. + * + * @param beaconIdToPlistFallback beacon ID → legacy XML plist (used only when the + * row's accessory_json is still NULL, e.g. for rows + * imported under FindMy 0.7.6). + */ + public Observable> toAccessoryRequests(Map beaconIdToPlistFallback) { + return Observable.fromCallable(() -> { + if (beaconIdToPlistFallback.isEmpty()) { + return java.util.Collections.emptyList(); + } + + final var dao = db.ownedBeaconDao(); + + List out = new ArrayList<>(beaconIdToPlistFallback.size()); + for (var entry : beaconIdToPlistFallback.entrySet()) { + final String beaconId = entry.getKey(); + + final OwnedBeacon row = dao.getById(beaconId); + String accessoryJson = row == null ? null : row.accessoryJson; + + if (accessoryJson == null) { + // Lazy backfill: rows imported under FindMy 0.7.6 have no accessory_json, + // and neither do rows whose import-time conversion failed. Prefer the + // plist retained on the row over the caller's copy - the row is the + // source of truth, and the caller's map may be stale. + final String plist = (row != null && row.content != null) + ? row.content + : entry.getValue(); + + final String alignmentPlist = row == null ? null : row.alignmentPlist; + accessoryJson = this.accessoryJsonConverter.convert(plist, alignmentPlist); + if (accessoryJson != null) { + dao.updateAccessoryJson(beaconId, accessoryJson); + Log.d(TAG, "Lazy-backfilled accessory_json for beaconId=" + beaconId); + } + } + + if (accessoryJson != null) { + out.add(new AccessoryRequest(beaconId, accessoryJson)); + } else { + // Dropped rather than passed on: FindMyAccessory.from_json would throw + // and take down the whole batch. A short result is better than none. + Log.w(TAG, "Skipping beaconId=" + beaconId + " - no accessory_json available"); + } + } + return out; + }).subscribeOn(Schedulers.io()); + } + + /** + * Persist a {@link FetchResult} from {@code PythonAppleService}: location reports + * go to the cache (delegating to {@link #storeToLocationCache}), and the freshly + * serialized {@code accessory_json} per beacon (which now carries the rolling-key + * alignment from FindMy 0.9.x — the issue #30 fix) is written back to the + * {@code OwnedBeacons} table. + */ + public Observable>> storeFetchResult(FetchResult fetchResult) { + return Observable.fromCallable(() -> { + final var dao = db.ownedBeaconDao(); + for (var entry : fetchResult.getUpdatedAccessoryJson().entrySet()) { + if (entry.getValue() != null) { + dao.updateAccessoryJson(entry.getKey(), entry.getValue()); + } + } + return fetchResult.getReports(); + }).subscribeOn(Schedulers.io()) + .flatMap(this::storeToLocationCache); + } + public Observable>> storeToLocationCache(Map> reportsForBeaconId) { return Observable.fromCallable(() -> { if (reportsForBeaconId.isEmpty()) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java index 7c7429a9..9b315157 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/UserSettingsRepository.java @@ -1,8 +1,10 @@ package dev.wander.android.opentagviewer.db.repo; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.AMAP_API_KEY; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ANISETTE_SERVER_URL; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.ENABLE_DEBUG_DATA; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.LANGUAGE; +import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.MAP_PROVIDER; import static dev.wander.android.opentagviewer.db.datastore.UserSettingsDataStore.USE_DARK_THEME; import androidx.datastore.preferences.core.MutablePreferences; @@ -28,12 +30,16 @@ public UserSettings getUserSettings() { String language = settings.get(LANGUAGE); Boolean useDarkTheme = settings.get(USE_DARK_THEME); Boolean enableDebugData = settings.get(ENABLE_DEBUG_DATA); + String mapProvider = settings.get(MAP_PROVIDER); + String amapApiKey = settings.get(AMAP_API_KEY); return UserSettings.builder() .anisetteServerUrl(anisetteServerUrl) .language(language) .useDarkTheme(useDarkTheme) .enableDebugData(enableDebugData) + .mapProvider(mapProvider) + .amapApiKey(amapApiKey) .build(); }).subscribeOn(Schedulers.io()) @@ -49,6 +55,10 @@ public Completable storeUserSettings(UserSettings userSettings) { mutablePreferences.set(LANGUAGE, userSettings.getLanguage()); mutablePreferences.set(USE_DARK_THEME, userSettings.getUseDarkTheme()); mutablePreferences.set(ENABLE_DEBUG_DATA, userSettings.getEnableDebugData()); + mutablePreferences.set(MAP_PROVIDER, userSettings.getMapProvider()); + // Null would throw; an empty string reads back as "no key supplied". + mutablePreferences.set(AMAP_API_KEY, + userSettings.getAmapApiKey() == null ? "" : userSettings.getAmapApiKey()); return Single.just(mutablePreferences); }).subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java index 7ddc3ec1..028670cc 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/repo/model/UserSettings.java @@ -10,8 +10,30 @@ public class UserSettings { private String anisetteServerUrl; private String language; private Boolean enableDebugData; + private String mapProvider; // "google" or "amap" + + /** + * The user's own AMap (高德地图) API key. + *
+ * Not shipped with the app: AMap keys are issued per developer account, bound to a + * package name and signing fingerprint, and their terms expect the key holder to be + * the app's operator. So anyone wanting AMap supplies their own, the same way the + * Anisette server URL works. + */ + private String amapApiKey; public boolean hasDarkThemeEnabled() { return this.useDarkTheme == Boolean.TRUE; } + + /** + * The selected map provider, defaulting to Google Maps. + */ + public String getMapProvider() { + return mapProvider != null && !mapProvider.isEmpty() ? mapProvider : "google"; + } + + public boolean hasAmapApiKey() { + return this.amapApiKey != null && !this.amapApiKey.isBlank(); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java index b061a49c..a39ac11c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/OpenTagViewerDatabase.java @@ -2,9 +2,12 @@ import android.content.Context; +import androidx.annotation.NonNull; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; import dev.wander.android.opentagviewer.db.room.dao.BeaconNamingRecordDao; import dev.wander.android.opentagviewer.db.room.dao.DailyHistoryFetchRecordDao; @@ -28,11 +31,38 @@ DailyHistoryFetchRecord.class, UserBeaconOptions.class }, - version = 1 + version = 3 ) public abstract class OpenTagViewerDatabase extends RoomDatabase { private static OpenTagViewerDatabase INSTANCE = null; + /** + * v1 → v2: adds {@code accessory_json} to {@code OwnedBeacons} for FindMy 0.9.x's + * stateful FindMyAccessory persistence (issue #30 fix). Pure additive ALTER — + * existing rows survive with NULL and are lazily backfilled on first fetch. + */ + public static final Migration MIGRATION_1_2 = new Migration(1, 2) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN accessory_json TEXT"); + } + }; + + /** + * v2 → v3: adds {@code alignment_plist} to {@code OwnedBeacons}, holding the + * KeyAlignmentRecord exported from macOS. + *
+ * Pure additive ALTER. Existing rows stay NULL, which is correct: their exports + * predate format 0.0.2 and genuinely have no alignment record. Those beacons keep + * working exactly as before, relying on the alignment probe in main.py instead. + */ + public static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE OwnedBeacons ADD COLUMN alignment_plist TEXT"); + } + }; + public static OpenTagViewerDatabase getInstance(Context context) { // Singleton pattern for single-process apps: https://developer.android.com/training/data-storage/room#java @@ -41,6 +71,7 @@ public static OpenTagViewerDatabase getInstance(Context context) { context, OpenTagViewerDatabase.class, "opentagviewer-db") + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build(); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java index 00f17e02..35f1f483 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/dao/OwnedBeaconDao.java @@ -27,6 +27,14 @@ public interface OwnedBeaconDao { @Query("UPDATE OwnedBeacons SET is_removed = 1 WHERE id = :beaconId") void setRemoved(String beaconId); + /** + * Persist the rolling-key alignment state (FindMy 0.9.x stateful FindMyAccessory) + * after a successful fetch. Targeted UPDATE so we don't risk clobbering other + * columns if the in-memory copy is stale. + */ + @Query("UPDATE OwnedBeacons SET accessory_json = :accessoryJson WHERE id = :beaconId") + void updateAccessoryJson(String beaconId, String accessoryJson); + @Delete void delete(OwnedBeacon ownedBeaconWithId); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java index 10cd9158..9fb21e7c 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/db/room/entity/OwnedBeacon.java @@ -45,4 +45,30 @@ public class OwnedBeacon { @ColumnInfo(name = "is_removed") public boolean isRemoved; + + /** + * Serialized FindMyAccessory state (JSON) for FindMy.py 0.9.x. Includes the + * rolling-key alignment that updates after every fetch — persisting it back + * across calls is what fixes the key-drift bug from issue #30. + * + * Nullable for rows imported under FindMy 0.7.6: lazily backfilled from + * {@link #content} on first fetch via {@code main.py:convertPlistToJson}. + */ + @ColumnInfo(name = "accessory_json") + public String accessoryJson; + + /** + * The KeyAlignmentRecord plist for this accessory, as exported from macOS. + *
+ * Supplies the rolling-key index macOS last observed, which + * {@code FindMyAccessory.from_plist(plist, key_alignment_plist)} uses as its starting + * point. Without it an accessory starts at index 0 from its pairing date, so the first + * fetch searches the tag's whole history - tens of thousands of keys for an older tag. + *
+ * Nullable: exports made before format 0.0.2 do not contain one, and macOS has none for + * accessories it has never observed. Retained rather than only converted, so a future + * FindMy.py that reads more of this record can re-derive from it. + */ + @ColumnInfo(name = "alignment_plist") + public String alignmentPlist; } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java new file mode 100644 index 00000000..6a0a8c96 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/AccessoryRequest.java @@ -0,0 +1,21 @@ +package dev.wander.android.opentagviewer.python; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Java→Python carrier for a single beacon's fetch input. Chaquopy exposes the + * getter methods to Python as attribute access (e.g. {@code req.getBeaconId()}) + * so the Python side can read fields without having to import any Java types. + * + *

Replaces the older {@code Pair} (beaconId → plistXml) shape + * that the 0.7.x bridge used. With FindMy 0.9.x we also need the persisted + * accessory JSON (which carries the rolling-key alignment state — the issue #30 + * fix) so it can be passed in and updated state can be returned. + */ +@AllArgsConstructor +@Getter +public class AccessoryRequest { + private final String beaconId; + private final String accessoryJson; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java new file mode 100644 index 00000000..487ac57f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/ChaquopyPlistToAccessoryJsonConverter.java @@ -0,0 +1,36 @@ +package dev.wander.android.opentagviewer.python; + +import android.util.Log; + +import com.chaquo.python.Python; + +/** + * The real converter: calls {@code main.py:convertPlistToJson}, which runs + * {@code FindMyAccessory.from_plist(...).to_json()}. + *
+ * The Python runtime is resolved lazily per call rather than held as a field, so + * constructing this does not require Chaquopy to have started. + */ +public class ChaquopyPlistToAccessoryJsonConverter implements PlistToAccessoryJsonConverter { + private static final String TAG = ChaquopyPlistToAccessoryJsonConverter.class.getSimpleName(); + private static final String MODULE_MAIN = "main"; + + @Override + public String convert(final String plistXml, final String alignmentPlistXml) { + if (plistXml == null || plistXml.isEmpty()) { + return null; + } + + try { + var module = Python.getInstance().getModule(MODULE_MAIN); + // Passing null for the alignment record is fine - Python treats it as absent. + var converted = module.callAttr("convertPlistToJson", plistXml, alignmentPlistXml); + return converted == null ? null : converted.toString(); + } catch (Exception e) { + // Either Python has not started yet, or the plist is not one FindMy 0.9.x can + // parse. Neither should take down the whole fetch, so report and move on. + Log.w(TAG, "convertPlistToJson failed", e); + return null; + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java new file mode 100644 index 00000000..eaf31571 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/FetchResult.java @@ -0,0 +1,22 @@ +package dev.wander.android.opentagviewer.python; + +import java.util.List; +import java.util.Map; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Wraps the result of a {@link PythonAppleService} fetch call. With FindMy 0.9.x + * the {@code FindMyAccessory} object is stateful — its rolling-key alignment is + * updated each fetch and must be persisted back to the DB to keep key drift + * from re-emerging. So a fetch returns both the location reports per beacon AND + * the freshly-serialized accessory JSON per beacon, keyed by beaconId. + */ +@AllArgsConstructor +@Getter +public class FetchResult { + private final Map> reports; + private final Map updatedAccessoryJson; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java new file mode 100644 index 00000000..f1255da2 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PlistToAccessoryJsonConverter.java @@ -0,0 +1,25 @@ +package dev.wander.android.opentagviewer.python; + +/** + * Converts a beacon's stored plist XML into the serialized {@code FindMyAccessory} JSON + * that FindMy.py 0.9.x expects. + *
+ * This is an interface rather than a static call so the conversion can be substituted in + * tests: the real implementation reaches into Chaquopy, which needs a started Python + * runtime and cannot run on the JVM. + */ +public interface PlistToAccessoryJsonConverter { + + /** + * @param plistXml the raw plist XML as stored in {@code OwnedBeacons.content} + * @param alignmentPlistXml the accessory's KeyAlignmentRecord plist, or {@code null}. + * Supplies the rolling-key index macOS last observed so fetching + * can start there instead of at the tag's pairing date. Absent + * for exports made before format 0.0.2. + * @return the serialized accessory JSON, or {@code null} if conversion was not + * possible. Callers should treat {@code null} as "not available right now" + * and retry later, not as a permanent failure - a conversion can fail simply + * because the Python runtime has not started yet. + */ + String convert(String plistXml, String alignmentPlistXml); +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java index fb9851e5..5b857913 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAppleService.java @@ -1,7 +1,6 @@ package dev.wander.android.opentagviewer.python; import android.util.Log; -import android.util.Pair; import com.chaquo.python.Kwarg; import com.chaquo.python.PyObject; @@ -13,7 +12,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import java.util.concurrent.locks.ReentrantLock; import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; import io.reactivex.rxjava3.core.Observable; @@ -37,81 +36,122 @@ public static PythonAppleService getInstance() { return INSTANCE; } + /** + * Serialises all calls into Python. + *
+ * FindMy.py's synchronous AppleAccount wraps an async one and drives it with a single + * asyncio event loop. RxJava schedules our fetches on a thread pool, and the periodic + * refresh in MapsActivity fires every 60 seconds regardless of whether the previous + * fetch has finished. Two fetches overlapping means two threads calling + * run_until_complete on the same loop, which fails with + * "RuntimeError: This event loop is already running" and then keeps failing. + *
+ * A fetch that takes longer than the refresh interval is entirely normal for an + * accessory with no alignment yet, so this is not a rare race. + *
+ * Serialising alone is not enough: the periodic refresh would still queue up behind a + * slow fetch, one entry per minute, and then fire the whole stale backlog at once when + * it finally drained. Callers on the periodic path should check {@link #isBusy()} and + * skip their turn instead - a refresh that is minutes late has no value. + */ + private static final ReentrantLock PYTHON_LOCK = new ReentrantLock(); + + /** + * Whether a call into Python is currently in progress. + *
+ * Advisory only. A caller that acts on this can still be beaten to the lock, which is + * harmless: it just waits, exactly as it did before. + */ + public static boolean isBusy() { + return PYTHON_LOCK.isLocked(); + } + private PythonAppleService(PythonAppleAccount account) { this.account = account; } - public Observable>> getLastReports(final Map beaconIdToPList, final int hoursToGoBack) { + public Observable getLastReports(final List requests, final int hoursToGoBack) { return Observable.fromCallable(() -> { - if (beaconIdToPList.isEmpty()) { - // if there's no items being requested, just return none immediately: - return Collections.>emptyMap(); + if (requests.isEmpty()) { + return emptyResult(); } - var py = Python.getInstance(); - var module = py.getModule(MODULE_MAIN); - - var asListOfPairs = beaconIdToPList.entrySet().stream() - .map(kvp -> Pair.create(kvp.getKey(), kvp.getValue())) - .collect(Collectors.toList()); - - var returned = module.callAttr( - "getLastReports", - new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToPList", asListOfPairs), - new Kwarg("hoursBack", hoursToGoBack) - ); - - // extract to result list... - if (returned == null) { - Log.e(TAG, "python call to getLastReports resulted in error (check python logs for details)"); - throw new PythonAppleFindMyException("Error while retrieving last reports for account via python!"); + PYTHON_LOCK.lock(); + try { + var py = Python.getInstance(); + var module = py.getModule(MODULE_MAIN); + + var returned = module.callAttr( + "getLastReports", + new Kwarg("account", this.account.getAccountObj()), + new Kwarg("idToAccessoryData", requests), + new Kwarg("hoursBack", hoursToGoBack) + ); + + if (returned == null) { + Log.e(TAG, "python call to getLastReports resulted in error (check python logs for details)"); + throw new PythonAppleFindMyException("Error while retrieving last reports for account via python!"); + } + + return mapResults(returned); + } finally { + PYTHON_LOCK.unlock(); } - - return mapResults(returned); - }).subscribeOn(Schedulers.io()); } - public Observable>> getReportsBetween(final Map beaconIdToPList, final long startTimeUnixMS, final long endTimeUnixMS) { + public Observable getReportsBetween(final List requests, final long startTimeUnixMS, final long endTimeUnixMS) { return Observable.fromCallable(() -> { - if (beaconIdToPList.isEmpty()) { - // if there's no items being requested, just return none immediately: - return Collections.>emptyMap(); + if (requests.isEmpty()) { + return emptyResult(); } - var py = Python.getInstance(); - var module = py.getModule(MODULE_MAIN); - - var asListOfPairs = beaconIdToPList.entrySet().stream() - .map(kvp -> Pair.create(kvp.getKey(), kvp.getValue())) - .collect(Collectors.toList()); - - var returned = module.callAttr( - "getReports", - new Kwarg("account", this.account.getAccountObj()), - new Kwarg("idToPList", asListOfPairs), - new Kwarg("unixStartMs", startTimeUnixMS), - new Kwarg("unixEndMs", endTimeUnixMS) - ); - - // extract to result list... - if (returned == null) { - Log.e(TAG, "python call to getReports resulted in error (check python logs for details)"); - throw new PythonAppleFindMyException("Error while retrieving time ranged reports for account via python!"); + PYTHON_LOCK.lock(); + try { + var py = Python.getInstance(); + var module = py.getModule(MODULE_MAIN); + + var returned = module.callAttr( + "getReports", + new Kwarg("account", this.account.getAccountObj()), + new Kwarg("idToAccessoryData", requests), + new Kwarg("unixStartMs", startTimeUnixMS), + new Kwarg("unixEndMs", endTimeUnixMS) + ); + + if (returned == null) { + Log.e(TAG, "python call to getReports resulted in error (check python logs for details)"); + throw new PythonAppleFindMyException("Error while retrieving time ranged reports for account via python!"); + } + + return mapResults(returned); + } finally { + PYTHON_LOCK.unlock(); } - - return mapResults(returned); }).subscribeOn(Schedulers.io()); } + private static FetchResult emptyResult() { + return new FetchResult(Collections.emptyMap(), Collections.emptyMap()); + } - private static Map> mapResults(final PyObject locationReportsResult) { + /** + * Python returns a dict shaped: + * { beaconId: { "reports": [reportDict, ...], "updatedAccessoryJson": "" } } + * + * We split it back into two parallel maps so callers can persist the updated + * accessory JSON via the OwnedBeaconDao (Phase 3) while consuming reports as before. + */ + private static FetchResult mapResults(final PyObject locationReportsResult) { Map> results = new HashMap<>(); + Map updatedAccessoryJson = new HashMap<>(); var mapBeaconIdToResult = locationReportsResult.asMap(); for (var key : mapBeaconIdToResult.keySet()) { - var locationReportList = mapBeaconIdToResult.get(key).asList(); + var perBeacon = mapBeaconIdToResult.get(key).asMap(); + + var locationReportList = perBeacon.get("reports").asList(); + var updatedAccessory = perBeacon.get("updatedAccessoryJson"); List reports = new LinkedList<>(); final int numReports = locationReportList.size(); @@ -142,9 +182,13 @@ private static Map> mapResults(final PyObject } reports.sort(Comparator.comparingLong(BeaconLocationReport::getTimestamp)); - results.put(key.toString(), reports); + String beaconIdStr = key.toString(); + results.put(beaconIdStr, reports); + if (updatedAccessory != null) { + updatedAccessoryJson.put(beaconIdStr, updatedAccessory.toString()); + } } - return results; + return new FetchResult(results, updatedAccessoryJson); } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java index 61444d08..ff26a4b0 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java @@ -123,7 +123,7 @@ public static Observable retrieveAuthData(@NonNull PythonAuthResponse au }).subscribeOn(Schedulers.computation()); } - public static Observable restoreAccount(final AppleUserData appleUserData, final String anisetteServerUrl) { + public static Observable restoreAccount(final AppleUserData appleUserData) { return Observable.fromCallable(() -> { var data = AppCryptographyUtil.AppEncryptedData.fromFlattened(appleUserData.getData()); var account = new AppCryptographyUtil().decrypt(data, KEYSTORE_ALIAS_ACCOUNT); @@ -131,10 +131,11 @@ public static Observable restoreAccount(final AppleUserData var py = Python.getInstance(); var module = py.getModule(MODULE_MAIN); + // FindMy 0.9.x embeds the anisette provider state inside the account JSON + // (see AccountStateMapping.anisette), so we no longer pass a server URL here. var returned = module.callAttr( "getAccount", - new Kwarg("serializedAccountData", new String(account, StandardCharsets.UTF_8)), - new Kwarg("anisetteServerUrl", anisetteServerUrl) + new Kwarg("serializedAccountData", new String(account, StandardCharsets.UTF_8)) ); if (returned == null) { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java index a973ee26..18ecffe9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/compat/WindowPaddingUtil.java @@ -29,4 +29,28 @@ public static void insertUITopPadding(View rootView) { return insets; }); } + + /** + * Keeps a bottom-anchored view clear of the navigation bar. + * + *

Activities that go edge to edge with + * {@code WindowCompat.setDecorFitsSystemWindows(window, false)} draw underneath the + * navigation bar, so anything aligned to the parent's bottom edge ends up behind it. That is + * invisible on gesture navigation, where the inset is only a few dp, and obvious on + * three-button navigation, where it is around 48dp. + * + * @param view The bottom-anchored view to pad. + */ + public static void insertUIBottomPadding(View view) { + ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding( + v.getPaddingLeft(), + v.getPaddingTop(), + v.getPaddingRight(), + systemBars.bottom + ); + return insets; + }); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java new file mode 100644 index 00000000..1cd240d8 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/AMapProvider.java @@ -0,0 +1,648 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.content.res.Configuration; +import android.util.Log; +import android.view.View; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 高德地图提供商实现 + * + * 高德地图Android SDK V9.8.3 (3D地图) + * 发布日期:2023-12-06 + * + * SDK依赖配置: + * dependencies { + * implementation 'com.amap.api:3dmap:9.8.3' + * // 注意:3D地图SDK已包含定位功能,无需单独引入location SDK + * } + * + * API Key配置: + * 在AndroidManifest.xml中添加: + * + * + * 隐私合规: + * 必须在Application的onCreate中调用隐私合规接口: + * MapsInitializer.updatePrivacyShow(context, true, true); + * MapsInitializer.updatePrivacyAgree(context, true); + * + * 参考文档: + * - 隐私合规:https://lbs.amap.com/api/android-sdk/guide/create-map/dev-attention + * - SDK初始化:https://lbs.amap.com/api/android-sdk/guide/create-project/android-studio-create-project + * - 入门指南:https://lbs.amap.com/api/android-sdk/gettingstarted + */ +public class AMapProvider implements IMapProvider { + private static final String TAG = AMapProvider.class.getSimpleName(); + + private Activity activity; + private OnMapReadyCallback callback; + + // 存储标记点和路径线 + private final Map markers = new HashMap<>(); // 存储AMap.Marker对象 + private final Map polylines = new HashMap<>(); // 存储Polyline对象 + + // 监听器 + private OnMapClickListener onMapClickListener; + private OnMarkerClickListener onMarkerClickListener; + + // 高德地图对象 + // 使用3D地图SDK:com.amap.api.maps.AMap + private Object aMap; // com.amap.api.maps.AMap + private View mapView; // com.amap.api.maps.TextureMapView + + private MapStyle currentMapStyle = MapStyle.FOLLOW_SYSTEM; + + @Override + public void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback) { + this.activity = activity; + this.callback = callback; + + try { + // 使用反射加载高德地图3D SDK,避免编译时依赖 + // 高德地图3D地图使用 TextureMapView 或 MapView + // TextureMapView 性能更好,推荐使用 + Class mapViewClass = Class.forName("com.amap.api.maps.TextureMapView"); + Object mapView = mapViewClass.getConstructor(android.content.Context.class) + .newInstance(activity); + + this.mapView = (View) mapView; + + // 获取AMap对象 + // com.amap.api.maps.AMap + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method getMapMethod = mapViewClass.getMethod("getMap"); + this.aMap = getMapMethod.invoke(mapView); + + // 调用 onCreate 生命周期方法(高德地图必需) + // TextureMapView.onCreate(Bundle savedInstanceState) + java.lang.reflect.Method onCreateMethod = mapViewClass.getMethod("onCreate", android.os.Bundle.class); + onCreateMethod.invoke(mapView, (android.os.Bundle) null); + Log.d(TAG, "AMap TextureMapView onCreate() called"); + + // 添加到容器,并设置 LayoutParams 确保 View 填充整个容器 + android.view.ViewGroup container = activity.findViewById(containerViewId); + if (container != null) { + android.view.ViewGroup.LayoutParams layoutParams = new android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ); + container.addView((View) mapView, layoutParams); + Log.d(TAG, "AMap view added to container with MATCH_PARENT layout params"); + } else { + Log.e(TAG, "Container view not found for id: " + containerViewId); + } + + // 设置地图准备就绪回调 + if (this.callback != null) { + this.callback.onMapReady(this); + Log.d(TAG, "AMap onMapReady callback triggered"); + } + + Log.i(TAG, "AMap initialization completed successfully"); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize AMap", e); + throw new RuntimeException("Failed to initialize AMap SDK. Please ensure AMap SDK is properly configured.", e); + } + } + + @Override + public void setMapStyle(MapStyle mapStyle) { + this.currentMapStyle = mapStyle == null ? MapStyle.FOLLOW_SYSTEM : mapStyle; + if (aMap == null) return; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method setMapTypeMethod = aMapClass.getMethod("setMapType", int.class); + + int normalMapType = aMapClass.getField("MAP_TYPE_NORMAL").getInt(null); + int nightMapType = aMapClass.getField("MAP_TYPE_NIGHT").getInt(null); + int mapType = this.shouldUseDarkMap() ? nightMapType : normalMapType; + setMapTypeMethod.invoke(aMap, mapType); + + Log.d(TAG, "Map style set to " + this.currentMapStyle + ", dark=" + this.shouldUseDarkMap()); + } catch (Exception e) { + Log.e(TAG, "Failed to set map style", e); + } + } + + private boolean shouldUseDarkMap() { + if (this.currentMapStyle == MapStyle.DARK) { + return true; + } + if (this.currentMapStyle == MapStyle.LIGHT) { + return false; + } + if (this.activity == null) { + return false; + } + int currentNightMode = this.activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return currentNightMode == Configuration.UI_MODE_NIGHT_YES; + } + + @Override + public String addMarker(MapMarker marker) { + if (aMap == null) { + Log.w(TAG, "AMap is not ready yet, cannot add marker"); + return marker.getId(); + } + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(marker.getLatitude(), marker.getLongitude()); + + // 创建MarkerOptions + // com.amap.api.maps.model.MarkerOptions + Class markerOptionsClass = Class.forName("com.amap.api.maps.model.MarkerOptions"); + Object markerOptions = markerOptionsClass.newInstance(); + + // 设置位置 + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + java.lang.reflect.Method positionMethod = markerOptionsClass.getMethod("position", latLngClass); + positionMethod.invoke(markerOptions, latLng); + + // 设置标题和片段 + if (marker.getTitle() != null) { + java.lang.reflect.Method titleMethod = markerOptionsClass.getMethod("title", String.class); + titleMethod.invoke(markerOptions, marker.getTitle()); + } + if (marker.getSnippet() != null) { + java.lang.reflect.Method snippetMethod = markerOptionsClass.getMethod("snippet", String.class); + snippetMethod.invoke(markerOptions, marker.getSnippet()); + } + + // 设置图标 + if (marker.getIconBitmap() != null) { + Class bitmapDescriptorClass = Class.forName("com.amap.api.maps.model.BitmapDescriptor"); + Class bitmapDescriptorFactoryClass = Class.forName("com.amap.api.maps.model.BitmapDescriptorFactory"); + java.lang.reflect.Method fromBitmapMethod = bitmapDescriptorFactoryClass.getMethod("fromBitmap", Bitmap.class); + Object bitmapDescriptor = fromBitmapMethod.invoke(null, marker.getIconBitmap()); + + java.lang.reflect.Method iconMethod = markerOptionsClass.getMethod("icon", bitmapDescriptorClass); + iconMethod.invoke(markerOptions, bitmapDescriptor); + } + + // 设置绘制顺序 + // Best-effort: AMap's MarkerOptions.zIndex(float) matches Google's, but this whole + // class talks to the SDK reflectively, so a rename in a future AMap version has to + // degrade to an unordered marker rather than no marker at all. + try { + java.lang.reflect.Method zIndexMethod = markerOptionsClass.getMethod("zIndex", float.class); + zIndexMethod.invoke(markerOptions, marker.getZIndex()); + } catch (Exception e) { + Log.w(TAG, "Could not set the marker draw order; overlapping tags may not raise on selection", e); + } + + // 添加标记 + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method addMarkerMethod = aMapClass.getMethod("addMarker", markerOptionsClass); + Object amapMarker = addMarkerMethod.invoke(aMap, markerOptions); + + markers.put(marker.getId(), amapMarker); + + return marker.getId(); + } catch (Exception e) { + Log.e(TAG, "Failed to add marker", e); + return marker.getId(); + } + } + + @Override + public void removeMarker(String markerId) { + Object marker = markers.remove(markerId); + if (marker != null) { + try { + java.lang.reflect.Method removeMethod = marker.getClass().getMethod("remove"); + removeMethod.invoke(marker); + } catch (Exception e) { + Log.e(TAG, "Failed to remove marker", e); + } + } + } + + @Override + public void setMarkerZIndex(String markerId, float zIndex) { + Object marker = markers.get(markerId); + if (marker == null) { + Log.d(TAG, "No marker to re-order for markerId=" + markerId); + return; + } + try { + java.lang.reflect.Method setZIndexMethod = marker.getClass().getMethod("setZIndex", float.class); + setZIndexMethod.invoke(marker, zIndex); + } catch (Exception e) { + // Not fatal: the marker stays where it is, just possibly behind another one. + Log.w(TAG, "Failed to re-order marker for markerId=" + markerId, e); + } + } + + @Override + public void clearMarkers() { + for (Object marker : markers.values()) { + try { + java.lang.reflect.Method removeMethod = marker.getClass().getMethod("remove"); + removeMethod.invoke(marker); + } catch (Exception e) { + Log.e(TAG, "Failed to remove marker", e); + } + } + markers.clear(); + } + + @Override + public String addPolyline(MapPolyline polyline) { + if (aMap == null) { + Log.w(TAG, "AMap is not ready yet, cannot add polyline"); + return polyline.getId(); + } + + try { + // 坐标转换:WGS84 -> GCJ02 + List gcj02Points = polyline.getPoints().stream() + .map(p -> { + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(p.getLatitude(), p.getLongitude()); + return new MapPolyline.LatLng(gcj02[0], gcj02[1]); + }) + .collect(Collectors.toList()); + + // 创建PolylineOptions + // com.amap.api.maps.model.PolylineOptions + Class polylineOptionsClass = Class.forName("com.amap.api.maps.model.PolylineOptions"); + Object polylineOptions = polylineOptionsClass.newInstance(); + + // 添加点 + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + java.lang.reflect.Method addMethod = polylineOptionsClass.getMethod("add", latLngClass); + + for (MapPolyline.LatLng point : gcj02Points) { + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(point.getLatitude(), point.getLongitude()); + addMethod.invoke(polylineOptions, latLng); + } + + // 设置颜色和宽度 + java.lang.reflect.Method colorMethod = polylineOptionsClass.getMethod("color", int.class); + colorMethod.invoke(polylineOptions, polyline.getColor()); + + java.lang.reflect.Method widthMethod = polylineOptionsClass.getMethod("width", float.class); + widthMethod.invoke(polylineOptions, polyline.getWidth()); + + // 添加路径线 + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method addPolylineMethod = aMapClass.getMethod("addPolyline", polylineOptionsClass); + Object amapPolyline = addPolylineMethod.invoke(aMap, polylineOptions); + + polylines.put(polyline.getId(), amapPolyline); + + return polyline.getId(); + } catch (Exception e) { + Log.e(TAG, "Failed to add polyline", e); + return polyline.getId(); + } + } + + @Override + public void removePolyline(String polylineId) { + Object polyline = polylines.remove(polylineId); + if (polyline != null) { + try { + java.lang.reflect.Method removeMethod = polyline.getClass().getMethod("remove"); + removeMethod.invoke(polyline); + } catch (Exception e) { + Log.e(TAG, "Failed to remove polyline", e); + } + } + } + + @Override + public void clearPolylines() { + for (Object polyline : polylines.values()) { + try { + java.lang.reflect.Method removeMethod = polyline.getClass().getMethod("remove"); + removeMethod.invoke(polyline); + } catch (Exception e) { + Log.e(TAG, "Failed to remove polyline", e); + } + } + polylines.clear(); + } + + @Override + public void moveCamera(double latitude, double longitude, float zoom) { + if (aMap == null) return; + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(latitude, longitude); + + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + // com.amap.api.maps.CameraUpdateFactory + Class cameraUpdateFactoryClass = Class.forName("com.amap.api.maps.CameraUpdateFactory"); + java.lang.reflect.Method newLatLngZoomMethod = cameraUpdateFactoryClass.getMethod( + "newLatLngZoom", latLngClass, float.class); + Object cameraUpdate = newLatLngZoomMethod.invoke(null, latLng, zoom); + + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method moveCameraMethod = aMapClass.getMethod("moveCamera", + Class.forName("com.amap.api.maps.CameraUpdate")); + moveCameraMethod.invoke(aMap, cameraUpdate); + } catch (Exception e) { + Log.e(TAG, "Failed to move camera", e); + } + } + + @Override + public void animateCamera(double latitude, double longitude, float zoom, Runnable callback) { + if (aMap == null) return; + + try { + // 坐标转换:WGS84 -> GCJ02 + double[] gcj02 = CoordinateConverter.wgs84ToGcj02(latitude, longitude); + + // com.amap.api.maps.model.LatLng + Class latLngClass = Class.forName("com.amap.api.maps.model.LatLng"); + Object latLng = latLngClass.getConstructor(double.class, double.class) + .newInstance(gcj02[0], gcj02[1]); + + // com.amap.api.maps.CameraUpdateFactory + Class cameraUpdateFactoryClass = Class.forName("com.amap.api.maps.CameraUpdateFactory"); + java.lang.reflect.Method newLatLngZoomMethod = cameraUpdateFactoryClass.getMethod( + "newLatLngZoom", latLngClass, float.class); + Object cameraUpdate = newLatLngZoomMethod.invoke(null, latLng, zoom); + + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + + if (callback != null) { + // 创建回调 + // com.amap.api.maps.AMap.CancelableCallback + Class cancelableCallbackClass = Class.forName("com.amap.api.maps.AMap$CancelableCallback"); + Object callbackObj = java.lang.reflect.Proxy.newProxyInstance( + cancelableCallbackClass.getClassLoader(), + new Class[]{cancelableCallbackClass}, + (proxy, method, args) -> { + if (method.getName().equals("onFinish") || method.getName().equals("onCancel")) { + callback.run(); + } + return null; + } + ); + + java.lang.reflect.Method animateCameraMethod = aMapClass.getMethod("animateCamera", + Class.forName("com.amap.api.maps.CameraUpdate"), + long.class, + cancelableCallbackClass); + animateCameraMethod.invoke(aMap, cameraUpdate, 1000L, callbackObj); + } else { + java.lang.reflect.Method animateCameraMethod = aMapClass.getMethod("animateCamera", + Class.forName("com.amap.api.maps.CameraUpdate")); + animateCameraMethod.invoke(aMap, cameraUpdate); + } + } catch (Exception e) { + Log.e(TAG, "Failed to animate camera", e); + } + } + + @Override + public void setOnMapClickListener(OnMapClickListener listener) { + this.onMapClickListener = listener; + if (aMap == null) return; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class onMapClickListenerClass = Class.forName("com.amap.api.maps.AMap$OnMapClickListener"); + + Object listenerObj = java.lang.reflect.Proxy.newProxyInstance( + onMapClickListenerClass.getClassLoader(), + new Class[]{onMapClickListenerClass}, + (proxy, method, args) -> { + if (method.getName().equals("onMapClick")) { + Object latLng = args[0]; + // 3D SDK中,LatLng的latitude和longitude是public字段 + java.lang.reflect.Field latitudeField = latLng.getClass().getField("latitude"); + java.lang.reflect.Field longitudeField = latLng.getClass().getField("longitude"); + double lat = latitudeField.getDouble(latLng); + double lon = longitudeField.getDouble(latLng); + + // 坐标转换:GCJ02 -> WGS84 + double[] wgs84 = CoordinateConverter.gcj02ToWgs84(lat, lon); + if (onMapClickListener != null) { + onMapClickListener.onMapClick(wgs84[0], wgs84[1]); + } + } + return null; + } + ); + + java.lang.reflect.Method setOnMapClickListenerMethod = aMapClass.getMethod("setOnMapClickListener", onMapClickListenerClass); + setOnMapClickListenerMethod.invoke(aMap, listenerObj); + } catch (Exception e) { + Log.e(TAG, "Failed to set map click listener", e); + } + } + + @Override + public void setOnMarkerClickListener(OnMarkerClickListener listener) { + this.onMarkerClickListener = listener; + if (aMap == null) return; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class onMarkerClickListenerClass = Class.forName("com.amap.api.maps.AMap$OnMarkerClickListener"); + + Object listenerObj = java.lang.reflect.Proxy.newProxyInstance( + onMarkerClickListenerClass.getClassLoader(), + new Class[]{onMarkerClickListenerClass}, + (proxy, method, args) -> { + if (method.getName().equals("onMarkerClick")) { + Object marker = args[0]; + String markerId = findMarkerId(marker); + if (onMarkerClickListener != null) { + return onMarkerClickListener.onMarkerClick(markerId); + } + } + return false; + } + ); + + java.lang.reflect.Method setOnMarkerClickListenerMethod = aMapClass.getMethod("setOnMarkerClickListener", onMarkerClickListenerClass); + setOnMarkerClickListenerMethod.invoke(aMap, listenerObj); + } catch (Exception e) { + Log.e(TAG, "Failed to set marker click listener", e); + } + } + + private String findMarkerId(Object marker) { + for (Map.Entry entry : markers.entrySet()) { + if (entry.getValue().equals(marker)) { + return entry.getKey(); + } + } + return null; + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + // 高德地图可能不支持setPadding,或者API不同 + // 需要根据实际SDK调整 + Log.d(TAG, "setPadding called: " + left + ", " + top + ", " + right + ", " + bottom); + } + + @Override + public CameraPosition getCameraPosition() { + if (aMap == null) return null; + + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + java.lang.reflect.Method getCameraPositionMethod = aMapClass.getMethod("getCameraPosition"); + Object cameraPosition = getCameraPositionMethod.invoke(aMap); + + // com.amap.api.maps.model.CameraPosition + // 3D SDK中,target是public字段 + java.lang.reflect.Field targetField = cameraPosition.getClass().getField("target"); + Object target = targetField.get(cameraPosition); + + // LatLng的latitude和longitude是public字段 + java.lang.reflect.Field latitudeField = target.getClass().getField("latitude"); + java.lang.reflect.Field longitudeField = target.getClass().getField("longitude"); + double lat = latitudeField.getDouble(target); + double lon = longitudeField.getDouble(target); + + // zoom也是public字段 + java.lang.reflect.Field zoomField = cameraPosition.getClass().getField("zoom"); + float zoom = zoomField.getFloat(cameraPosition); + + // 坐标转换:GCJ02 -> WGS84 + double[] wgs84 = CoordinateConverter.gcj02ToWgs84(lat, lon); + + return new CameraPosition(wgs84[0], wgs84[1], zoom); + } catch (Exception e) { + Log.e(TAG, "Failed to get camera position", e); + return null; + } + } + + @Override + public void setMyLocationButtonEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setMyLocationButtonEnabledMethod = uiSettingsClass.getMethod("setMyLocationButtonEnabled", boolean.class); + setMyLocationButtonEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set my location button enabled", e); + } + } + + @Override + public void setRotateGesturesEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setRotateGesturesEnabledMethod = uiSettingsClass.getMethod("setRotateGesturesEnabled", boolean.class); + setRotateGesturesEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set rotate gestures enabled", e); + } + } + + @Override + public void setCompassEnabled(boolean enabled) { + if (aMap == null) return; + try { + Class aMapClass = Class.forName("com.amap.api.maps.AMap"); + Class uiSettingsClass = Class.forName("com.amap.api.maps.UiSettings"); + java.lang.reflect.Method getUiSettingsMethod = aMapClass.getMethod("getUiSettings"); + Object uiSettings = getUiSettingsMethod.invoke(aMap); + + java.lang.reflect.Method setCompassEnabledMethod = uiSettingsClass.getMethod("setCompassEnabled", boolean.class); + setCompassEnabledMethod.invoke(uiSettings, enabled); + } catch (Exception e) { + Log.e(TAG, "Failed to set compass enabled", e); + } + } + + @Override + public void setMapToolbarEnabled(boolean enabled) { + // 高德地图可能没有工具栏,或者API不同 + Log.d(TAG, "setMapToolbarEnabled called: " + enabled); + } + + @Override + public void clear() { + clearMarkers(); + clearPolylines(); + } + + @Override + public View getMapView() { + return mapView; + } + + /** + * 在Activity的onResume中调用 + */ + public void onResume() { + if (mapView != null) { + try { + java.lang.reflect.Method onResumeMethod = mapView.getClass().getMethod("onResume"); + onResumeMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onResume on map view", e); + } + } + } + + /** + * 在Activity的onPause中调用 + */ + public void onPause() { + if (mapView != null) { + try { + java.lang.reflect.Method onPauseMethod = mapView.getClass().getMethod("onPause"); + onPauseMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onPause on map view", e); + } + } + } + + /** + * 在Activity的onDestroy中调用 + */ + public void onDestroy() { + if (mapView != null) { + try { + java.lang.reflect.Method onDestroyMethod = mapView.getClass().getMethod("onDestroy"); + onDestroyMethod.invoke(mapView); + } catch (Exception e) { + Log.e(TAG, "Failed to call onDestroy on map view", e); + } + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java new file mode 100644 index 00000000..ff026d6c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/CoordinateConverter.java @@ -0,0 +1,89 @@ +package dev.wander.android.opentagviewer.ui.maps; + +/** + * 坐标系转换工具 + * Google Maps使用WGS84坐标系(GPS原始坐标) + * 高德地图使用GCJ02坐标系(火星坐标,中国加密后的坐标) + */ +public class CoordinateConverter { + + private static final double PI = 3.1415926535897932384626; + private static final double A = 6378245.0; // 长半轴 + private static final double EE = 0.00669342162296594323; // 偏心率平方 + + /** + * WGS84转GCJ02(GPS坐标转火星坐标) + * @param wgsLat WGS84纬度 + * @param wgsLon WGS84经度 + * @return GCJ02坐标 [纬度, 经度] + */ + public static double[] wgs84ToGcj02(double wgsLat, double wgsLon) { + if (isOutOfChina(wgsLat, wgsLon)) { + return new double[]{wgsLat, wgsLon}; + } + double dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0); + double dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0); + double radLat = wgsLat / 180.0 * PI; + double magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + double sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); + dLon = (dLon * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); + double mgLat = wgsLat + dLat; + double mgLon = wgsLon + dLon; + return new double[]{mgLat, mgLon}; + } + + /** + * GCJ02转WGS84(火星坐标转GPS坐标) + * @param gcjLat GCJ02纬度 + * @param gcjLon GCJ02经度 + * @return WGS84坐标 [纬度, 经度] + */ + public static double[] gcj02ToWgs84(double gcjLat, double gcjLon) { + if (isOutOfChina(gcjLat, gcjLon)) { + return new double[]{gcjLat, gcjLon}; + } + double dLat = transformLat(gcjLon - 105.0, gcjLat - 35.0); + double dLon = transformLon(gcjLon - 105.0, gcjLat - 35.0); + double radLat = gcjLat / 180.0 * PI; + double magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + double sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); + dLon = (dLon * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); + double mgLat = gcjLat + dLat; + double mgLon = gcjLon + dLon; + return new double[]{gcjLat * 2 - mgLat, gcjLon * 2 - mgLon}; + } + + /** + * 判断是否在中国范围外 + */ + private static boolean isOutOfChina(double lat, double lon) { + return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271; + } + + /** + * 纬度转换 + */ + private static double transformLat(double x, double y) { + double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0; + ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0; + return ret; + } + + /** + * 经度转换 + */ + private static double transformLon(double x, double y) { + double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0; + ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0; + return ret; + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java new file mode 100644 index 00000000..6c09f1bd --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/GoogleMapProvider.java @@ -0,0 +1,384 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.BitmapDescriptor; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.Polyline; +import com.google.android.gms.maps.model.PolylineOptions; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import dev.wander.android.opentagviewer.R; + +/** + * Google Maps地图提供商实现 + */ +public class GoogleMapProvider implements IMapProvider, OnMapReadyCallback { + private static final String TAG = GoogleMapProvider.class.getSimpleName(); + + private Activity activity; + private GoogleMap googleMap; + private SupportMapFragment mapFragment; + private IMapProvider.OnMapReadyCallback callback; + + // 存储标记点和路径线 + private final Map markers = new HashMap<>(); + private final Map polylines = new HashMap<>(); + + // 监听器 + private OnMapClickListener onMapClickListener; + private OnMarkerClickListener onMarkerClickListener; + private MapStyle currentMapStyle = MapStyle.FOLLOW_SYSTEM; + + @Override + public void initialize(Activity activity, int containerViewId, IMapProvider.OnMapReadyCallback callback) { + this.activity = activity; + this.callback = callback; + + if (!(activity instanceof androidx.fragment.app.FragmentActivity)) { + throw new IllegalArgumentException("Activity must be a FragmentActivity to use Google Maps"); + } + + FragmentManager fragmentManager = ((androidx.fragment.app.FragmentActivity) activity).getSupportFragmentManager(); + + // 查找或创建SupportMapFragment + mapFragment = (SupportMapFragment) fragmentManager.findFragmentById(containerViewId); + if (mapFragment == null) { + mapFragment = SupportMapFragment.newInstance(); + fragmentManager.beginTransaction() + .replace(containerViewId, mapFragment) + .commit(); + } + + mapFragment.getMapAsync(this); + } + + @Override + public void onMapReady(@NonNull GoogleMap googleMap) { + this.googleMap = googleMap; + + // 设置默认UI设置 + googleMap.getUiSettings().setMyLocationButtonEnabled(false); + googleMap.getUiSettings().setRotateGesturesEnabled(false); + googleMap.getUiSettings().setCompassEnabled(false); + googleMap.getUiSettings().setMapToolbarEnabled(false); + + // 设置监听器 + if (onMapClickListener != null) { + googleMap.setOnMapClickListener(point -> { + if (onMapClickListener != null) { + onMapClickListener.onMapClick(point.latitude, point.longitude); + } + }); + } + + if (onMarkerClickListener != null) { + googleMap.setOnMarkerClickListener(marker -> { + if (onMarkerClickListener != null) { + // 查找标记点ID + String markerId = findMarkerId(marker); + return onMarkerClickListener.onMarkerClick(markerId); + } + return false; + }); + } + + applyMapStyle(); + + // 通知回调 + if (callback != null) { + callback.onMapReady(this); + } + } + + private String findMarkerId(Marker marker) { + for (Map.Entry entry : markers.entrySet()) { + if (entry.getValue().equals(marker)) { + return entry.getKey(); + } + } + return null; + } + + @Override + public void setMapStyle(MapStyle mapStyle) { + this.currentMapStyle = mapStyle == null ? MapStyle.FOLLOW_SYSTEM : mapStyle; + applyMapStyle(); + } + + private void applyMapStyle() { + if (googleMap == null) return; + + try { + Class mapColorSchemeClass = Class.forName("com.google.android.gms.maps.model.MapColorScheme"); + int colorScheme; + if (this.currentMapStyle == MapStyle.DARK) { + colorScheme = mapColorSchemeClass.getField("DARK").getInt(null); + } else if (this.currentMapStyle == MapStyle.LIGHT) { + colorScheme = mapColorSchemeClass.getField("LIGHT").getInt(null); + } else { + colorScheme = mapColorSchemeClass.getField("FOLLOW_SYSTEM").getInt(null); + } + + GoogleMap.class.getMethod("setMapColorScheme", int.class).invoke(googleMap, colorScheme); + } catch (Exception e) { + Log.w(TAG, "Failed to apply Google Maps color scheme, falling back to default map appearance", e); + } + } + + @Override + public String addMarker(MapMarker marker) { + if (googleMap == null) { + Log.w(TAG, "GoogleMap is not ready yet, cannot add marker"); + return marker.getId(); + } + + LatLng position = new LatLng(marker.getLatitude(), marker.getLongitude()); + MarkerOptions options = new MarkerOptions() + .position(position) + .title(marker.getTitle()) + .snippet(marker.getSnippet()) + .draggable(marker.isDraggable()) + .visible(marker.isVisible()) + .alpha(marker.getAlpha()) + .zIndex(marker.getZIndex()); + + // 设置图标 + if (marker.getIconBitmap() != null) { + try { + options.icon(BitmapDescriptorFactory.fromBitmap(marker.getIconBitmap())); + } catch (Exception e) { + Log.e(TAG, "Failed to create icon from bitmap, falling back to default", e); + options.icon(BitmapDescriptorFactory.defaultMarker()); + } + } else if (marker.getIconResourceId() != 0) { + options.icon(BitmapDescriptorFactory.fromResource(marker.getIconResourceId())); + } else if (!marker.isUseDefaultIcon()) { + // 如果没有指定图标且不使用默认图标,使用默认图标 + options.icon(BitmapDescriptorFactory.defaultMarker()); + } + + Marker googleMarker = googleMap.addMarker(options); + markers.put(marker.getId(), googleMarker); + + return marker.getId(); + } + + @Override + public void removeMarker(String markerId) { + Marker marker = markers.remove(markerId); + if (marker != null) { + marker.remove(); + } + } + + @Override + public void setMarkerZIndex(String markerId, float zIndex) { + Marker marker = markers.get(markerId); + if (marker == null) { + Log.d(TAG, "No marker to re-order for markerId=" + markerId); + return; + } + marker.setZIndex(zIndex); + } + + @Override + public void clearMarkers() { + for (Marker marker : markers.values()) { + marker.remove(); + } + markers.clear(); + } + + @Override + public String addPolyline(MapPolyline polyline) { + if (googleMap == null) { + Log.w(TAG, "GoogleMap is not ready yet, cannot add polyline"); + return polyline.getId(); + } + + List points = polyline.getPoints().stream() + .map(p -> new LatLng(p.getLatitude(), p.getLongitude())) + .collect(Collectors.toList()); + + PolylineOptions options = new PolylineOptions() + .addAll(points) + .color(polyline.getColor()) + .width(polyline.getWidth()) + .zIndex(polyline.getZIndex()) + .geodesic(polyline.isGeodesic()) + .visible(polyline.isVisible()) + .clickable(false); + + // 设置透明度 + int alpha = (int) (polyline.getAlpha() * 255); + int colorWithAlpha = (alpha << 24) | (polyline.getColor() & 0x00FFFFFF); + options.color(colorWithAlpha); + + Polyline googlePolyline = googleMap.addPolyline(options); + polylines.put(polyline.getId(), googlePolyline); + + return polyline.getId(); + } + + @Override + public void removePolyline(String polylineId) { + Polyline polyline = polylines.remove(polylineId); + if (polyline != null) { + polyline.remove(); + } + } + + @Override + public void clearPolylines() { + for (Polyline polyline : polylines.values()) { + polyline.remove(); + } + polylines.clear(); + } + + @Override + public void moveCamera(double latitude, double longitude, float zoom) { + if (googleMap == null) return; + LatLng position = new LatLng(latitude, longitude); + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); + } + + @Override + public void animateCamera(double latitude, double longitude, float zoom, Runnable callback) { + if (googleMap == null) return; + LatLng position = new LatLng(latitude, longitude); + + if (callback != null) { + googleMap.animateCamera( + CameraUpdateFactory.newLatLngZoom(position, zoom), + new GoogleMap.CancelableCallback() { + @Override + public void onFinish() { + callback.run(); + } + + @Override + public void onCancel() { + callback.run(); + } + } + ); + } else { + googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, zoom)); + } + } + + @Override + public void setOnMapClickListener(OnMapClickListener listener) { + this.onMapClickListener = listener; + if (googleMap != null && listener != null) { + googleMap.setOnMapClickListener(point -> { + listener.onMapClick(point.latitude, point.longitude); + }); + } + } + + @Override + public void setOnMarkerClickListener(OnMarkerClickListener listener) { + this.onMarkerClickListener = listener; + if (googleMap != null && listener != null) { + googleMap.setOnMarkerClickListener(marker -> { + String markerId = findMarkerId(marker); + return listener.onMarkerClick(markerId); + }); + } + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + if (googleMap == null) return; + googleMap.setPadding(left, top, right, bottom); + } + + @Override + public CameraPosition getCameraPosition() { + if (googleMap == null) return null; + com.google.android.gms.maps.model.CameraPosition position = googleMap.getCameraPosition(); + return new CameraPosition( + position.target.latitude, + position.target.longitude, + position.zoom + ); + } + + @Override + public void setMyLocationButtonEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setMyLocationButtonEnabled(enabled); + } + + @Override + public void setRotateGesturesEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setRotateGesturesEnabled(enabled); + } + + @Override + public void setCompassEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setCompassEnabled(enabled); + } + + @Override + public void setMapToolbarEnabled(boolean enabled) { + if (googleMap == null) return; + googleMap.getUiSettings().setMapToolbarEnabled(enabled); + } + + @Override + public void clear() { + clearMarkers(); + clearPolylines(); + } + + @Override + public View getMapView() { + return mapFragment != null ? mapFragment.getView() : null; + } + + /** + * 获取底层的GoogleMap对象(用于特殊操作) + */ + public GoogleMap getGoogleMap() { + return googleMap; + } + + /** + * 动画移动到边界框 + */ + public void animateCameraToBounds(List points, int padding) { + if (googleMap == null || points == null || points.size() < 2) return; + + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + for (MapPolyline.LatLng point : points) { + builder.include(new LatLng(point.getLatitude(), point.getLongitude())); + } + LatLngBounds bounds = builder.build(); + googleMap.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java new file mode 100644 index 00000000..34394c4e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/IMapProvider.java @@ -0,0 +1,223 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.view.View; + +/** + * 地图提供商抽象接口 + * 定义统一的地图操作API,支持多地图SDK切换 + * + * 实现类: + * - GoogleMapProvider: Google Maps实现 + * - AMapProvider: 高德地图实现 + */ +public interface IMapProvider { + + /** + * 初始化地图 + * @param activity 当前Activity + * @param containerViewId 地图容器View的ID + * @param callback 地图就绪回调 + */ + void initialize(Activity activity, int containerViewId, OnMapReadyCallback callback); + + /** + * 设置地图样式 + */ + void setMapStyle(MapStyle mapStyle); + + /** + * 添加标记点 + * @param marker 标记点数据 + * @return 标记点ID + */ + String addMarker(MapMarker marker); + + /** + * 移除标记点 + * @param markerId 标记点ID + */ + void removeMarker(String markerId); + + /** + * 设置标记点的绘制顺序(数值大的显示在上层) + *
+ * Raises or lowers an existing marker. Tags kept in the same place overlap completely at + * anything but the closest zoom, so selecting one has to bring it to the front - otherwise + * the user taps a card and nothing visibly happens. + * + * @param markerId 标记点ID + * @param zIndex 绘制顺序 + */ + void setMarkerZIndex(String markerId, float zIndex); + + /** + * 清除所有标记点 + */ + void clearMarkers(); + + /** + * 添加路径线 + * @param polyline 路径线数据 + * @return 路径线ID + */ + String addPolyline(MapPolyline polyline); + + /** + * 移除路径线 + * @param polylineId 路径线ID + */ + void removePolyline(String polylineId); + + /** + * 清除所有路径线 + */ + void clearPolylines(); + + /** + * 移动相机(不带动画) + * @param latitude 纬度 + * @param longitude 经度 + * @param zoom 缩放级别 + */ + void moveCamera(double latitude, double longitude, float zoom); + + /** + * 动画移动相机 + * @param latitude 纬度 + * @param longitude 经度 + * @param zoom 缩放级别 + * @param callback 动画完成回调(可为null) + */ + void animateCamera(double latitude, double longitude, float zoom, Runnable callback); + + /** + * 设置地图点击监听器 + * @param listener 点击监听器 + */ + void setOnMapClickListener(OnMapClickListener listener); + + /** + * 设置标记点击监听器 + * @param listener 点击监听器 + */ + void setOnMarkerClickListener(OnMarkerClickListener listener); + + /** + * 设置地图内边距 + * @param left 左边距 + * @param top 上边距 + * @param right 右边距 + * @param bottom 下边距 + */ + void setPadding(int left, int top, int right, int bottom); + + /** + * 获取当前相机位置 + * @return 相机位置信息 + */ + CameraPosition getCameraPosition(); + + /** + * 设置我的位置按钮是否可见 + * @param enabled true为可见 + */ + void setMyLocationButtonEnabled(boolean enabled); + + /** + * 设置旋转手势是否可用 + * @param enabled true为可用 + */ + void setRotateGesturesEnabled(boolean enabled); + + /** + * 设置指南针是否可见 + * @param enabled true为可见 + */ + void setCompassEnabled(boolean enabled); + + /** + * 设置地图工具栏是否可见 + * @param enabled true为可见 + */ + void setMapToolbarEnabled(boolean enabled); + + /** + * 清除地图上所有覆盖物(标记点、路径线等) + */ + void clear(); + + /** + * 获取地图View对象 + * @return 地图View + */ + View getMapView(); + + /** + * 地图就绪回调接口 + */ + interface OnMapReadyCallback { + /** + * 地图已就绪,可以开始操作 + * @param mapProvider 地图提供商实例 + */ + void onMapReady(IMapProvider mapProvider); + } + + /** + * 地图点击监听器 + */ + interface OnMapClickListener { + /** + * 地图被点击 + * @param latitude 点击位置的纬度 + * @param longitude 点击位置的经度 + */ + void onMapClick(double latitude, double longitude); + } + + /** + * 标记点击监听器 + */ + interface OnMarkerClickListener { + /** + * 标记点被点击 + * @param markerId 标记点ID + * @return true表示消费事件,false表示不消费 + */ + boolean onMarkerClick(String markerId); + } + + /** + * 相机位置信息 + */ + class CameraPosition { + private final double latitude; + private final double longitude; + private final float zoom; + + public CameraPosition(double latitude, double longitude, float zoom) { + this.latitude = latitude; + this.longitude = longitude; + this.zoom = zoom; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public float getZoom() { + return zoom; + } + } + + enum MapStyle { + LIGHT, + DARK, + FOLLOW_SYSTEM + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java new file mode 100644 index 00000000..8d98518f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapMarker.java @@ -0,0 +1,217 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.graphics.Bitmap; +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.Nullable; + +/** + * 地图标记点数据类 + */ +public class MapMarker { + private final double latitude; + private final double longitude; + private final String title; + private final String snippet; + private final String id; + + // 图标相关 + @Nullable + private final Bitmap iconBitmap; + @DrawableRes + private final int iconResourceId; + private final boolean useDefaultIcon; + + // 标记颜色(用于默认图标) + @ColorInt + private final int markerColor; + + // 是否可拖拽 + private final boolean draggable; + + // 是否可见 + private final boolean visible; + + // 透明度(0.0 - 1.0) + private final float alpha; + + // 绘制顺序:数值大的标记显示在上层 + // Draw order. Tags in the same building overlap completely at anything but the closest + // zoom, so the selected one has to be raised or it stays hidden behind whichever marker + // the map happened to draw last. + private final float zIndex; + + private MapMarker(Builder builder) { + this.latitude = builder.latitude; + this.longitude = builder.longitude; + this.title = builder.title; + this.snippet = builder.snippet; + this.id = builder.id; + this.iconBitmap = builder.iconBitmap; + this.iconResourceId = builder.iconResourceId; + this.useDefaultIcon = builder.useDefaultIcon; + this.markerColor = builder.markerColor; + this.draggable = builder.draggable; + this.visible = builder.visible; + this.alpha = builder.alpha; + this.zIndex = builder.zIndex; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public String getTitle() { + return title; + } + + public String getSnippet() { + return snippet; + } + + public String getId() { + return id; + } + + @Nullable + public Bitmap getIconBitmap() { + return iconBitmap; + } + + @DrawableRes + public int getIconResourceId() { + return iconResourceId; + } + + public boolean isUseDefaultIcon() { + return useDefaultIcon; + } + + @ColorInt + public int getMarkerColor() { + return markerColor; + } + + public boolean isDraggable() { + return draggable; + } + + public boolean isVisible() { + return visible; + } + + public float getAlpha() { + return alpha; + } + + public float getZIndex() { + return zIndex; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private double latitude; + private double longitude; + private String title; + private String snippet; + private String id; + private Bitmap iconBitmap; + @DrawableRes + private int iconResourceId = 0; + private boolean useDefaultIcon = true; + @ColorInt + private int markerColor = 0xFF000000; // 默认黑色 + private boolean draggable = false; + private boolean visible = true; + private float alpha = 1.0f; + private float zIndex = 0.0f; + + public Builder latitude(double latitude) { + this.latitude = latitude; + return this; + } + + public Builder longitude(double longitude) { + this.longitude = longitude; + return this; + } + + public Builder position(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder snippet(String snippet) { + this.snippet = snippet; + return this; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder icon(Bitmap iconBitmap) { + this.iconBitmap = iconBitmap; + this.useDefaultIcon = false; + return this; + } + + public Builder iconResource(@DrawableRes int iconResourceId) { + this.iconResourceId = iconResourceId; + this.useDefaultIcon = false; + return this; + } + + public Builder useDefaultIcon(boolean useDefaultIcon) { + this.useDefaultIcon = useDefaultIcon; + return this; + } + + public Builder markerColor(@ColorInt int markerColor) { + this.markerColor = markerColor; + return this; + } + + public Builder draggable(boolean draggable) { + this.draggable = draggable; + return this; + } + + public Builder visible(boolean visible) { + this.visible = visible; + return this; + } + + public Builder alpha(float alpha) { + this.alpha = Math.max(0.0f, Math.min(1.0f, alpha)); + return this; + } + + public Builder zIndex(float zIndex) { + this.zIndex = zIndex; + return this; + } + + public MapMarker build() { + if (id == null || id.isEmpty()) { + id = "marker_" + System.currentTimeMillis() + "_" + Math.random(); + } + return new MapMarker(this); + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java new file mode 100644 index 00000000..fd86033c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapPolyline.java @@ -0,0 +1,200 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import androidx.annotation.ColorInt; + +import java.util.List; + +/** + * 地图路径线数据类 + */ +public class MapPolyline { + private final List points; + private final String id; + + // 样式相关 + @ColorInt + private final int color; + private final float width; + private final float zIndex; + private final boolean geodesic; + private final boolean visible; + + // 透明度(0.0 - 1.0) + private final float alpha; + + // 虚线模式(某些地图SDK支持) + private final boolean dotted; + private final float[] pattern; // 虚线模式数组 + + private MapPolyline(Builder builder) { + this.points = builder.points; + this.id = builder.id; + this.color = builder.color; + this.width = builder.width; + this.zIndex = builder.zIndex; + this.geodesic = builder.geodesic; + this.visible = builder.visible; + this.alpha = builder.alpha; + this.dotted = builder.dotted; + this.pattern = builder.pattern; + } + + public List getPoints() { + return points; + } + + public String getId() { + return id; + } + + @ColorInt + public int getColor() { + return color; + } + + public float getWidth() { + return width; + } + + public float getZIndex() { + return zIndex; + } + + public boolean isGeodesic() { + return geodesic; + } + + public boolean isVisible() { + return visible; + } + + public float getAlpha() { + return alpha; + } + + public boolean isDotted() { + return dotted; + } + + public float[] getPattern() { + return pattern; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List points; + private String id; + @ColorInt + private int color = 0xFF0000FF; // 默认蓝色 + private float width = 10.0f; + private float zIndex = 0.0f; + private boolean geodesic = false; + private boolean visible = true; + private float alpha = 1.0f; + private boolean dotted = false; + private float[] pattern = null; + + public Builder points(List points) { + this.points = points; + return this; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder color(@ColorInt int color) { + this.color = color; + return this; + } + + public Builder width(float width) { + this.width = Math.max(1.0f, width); + return this; + } + + public Builder zIndex(float zIndex) { + this.zIndex = zIndex; + return this; + } + + public Builder geodesic(boolean geodesic) { + this.geodesic = geodesic; + return this; + } + + public Builder visible(boolean visible) { + this.visible = visible; + return this; + } + + public Builder alpha(float alpha) { + this.alpha = Math.max(0.0f, Math.min(1.0f, alpha)); + return this; + } + + public Builder dotted(boolean dotted) { + this.dotted = dotted; + return this; + } + + public Builder pattern(float[] pattern) { + this.pattern = pattern; + return this; + } + + public MapPolyline build() { + if (points == null || points.isEmpty()) { + throw new IllegalArgumentException("Polyline must have at least one point"); + } + if (id == null || id.isEmpty()) { + id = "polyline_" + System.currentTimeMillis() + "_" + Math.random(); + } + return new MapPolyline(this); + } + } + + /** + * 经纬度坐标点 + */ + public static class LatLng { + private final double latitude; + private final double longitude; + + public LatLng(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LatLng latLng = (LatLng) o; + return Double.compare(latLng.latitude, latitude) == 0 && + Double.compare(latLng.longitude, longitude) == 0; + } + + @Override + public int hashCode() { + long temp = Double.doubleToLongBits(latitude); + int result = (int) (temp ^ (temp >>> 32)); + temp = Double.doubleToLongBits(longitude); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + return result; + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java new file mode 100644 index 00000000..a4599be1 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/MapProviderFactory.java @@ -0,0 +1,34 @@ +package dev.wander.android.opentagviewer.ui.maps; + +import android.app.Activity; +import android.util.Log; + +/** + * 地图提供商工厂类 + * 根据用户设置创建对应的地图提供商实例 + */ +public class MapProviderFactory { + private static final String TAG = MapProviderFactory.class.getSimpleName(); + + public static final String PROVIDER_GOOGLE = "google"; + public static final String PROVIDER_AMAP = "amap"; + + /** + * 创建地图提供商实例 + * @param providerType 提供商类型 ("google" 或 "amap") + * @return 地图提供商实例 + */ + public static IMapProvider create(String providerType) { + if (providerType == null || providerType.isEmpty() || PROVIDER_GOOGLE.equals(providerType)) { + Log.d(TAG, "Creating Google Maps provider"); + return new GoogleMapProvider(); + } else if (PROVIDER_AMAP.equals(providerType)) { + Log.d(TAG, "Creating AMap provider"); + return new AMapProvider(); + } else { + Log.w(TAG, "Unknown provider type: " + providerType + ", defaulting to Google Maps"); + return new GoogleMapProvider(); + } + } +} + diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java index bdd6a1cc..2cfe422a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/maps/VectorImageGeneratorUtil.java @@ -21,9 +21,6 @@ import androidx.core.content.res.ResourcesCompat; import androidx.core.graphics.drawable.DrawableCompat; -import com.google.android.gms.maps.model.BitmapDescriptor; -import com.google.android.gms.maps.model.BitmapDescriptorFactory; - import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -42,7 +39,7 @@ public final class VectorImageGeneratorUtil { private static final String TAG = VectorImageGeneratorUtil.class.getSimpleName(); - private static final Map BITMAP_CACHE = new HashMap<>(); + private static final Map BITMAP_CACHE = new HashMap<>(); @ColorInt private static final int COLOR_BLACK = 0xFF000000; @ColorInt private static final int COLOR_BLACK_ALPHA_100 = 0xD1000000; @@ -59,19 +56,19 @@ public final class VectorImageGeneratorUtil { private static final float EMOJI_TEXT_SIZE = 60; - public static BitmapDescriptor vectorToBitmap(@NonNull Resources resources, @DrawableRes int id) { + public static Bitmap vectorToBitmap(@NonNull Resources resources, @DrawableRes int id) { Drawable vectorDrawable = Objects.requireNonNull(ResourcesCompat.getDrawable(resources, id, null)); Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); vectorDrawable.draw(canvas); - return BitmapDescriptorFactory.fromBitmap(bitmap); + return bitmap; } /** * Create marker with emoji centred in the middle of it (cached) */ - public static BitmapDescriptor makeMarker(@NonNull Resources resources, final String emoji, @ColorInt int markerColor) { + public static Bitmap makeMarker(@NonNull Resources resources, final String emoji, @ColorInt int markerColor) { final String key = String.format(Locale.ROOT, "%s-%d", emoji, markerColor); if (BITMAP_CACHE.containsKey(key)) { return BITMAP_CACHE.get(key); @@ -93,15 +90,14 @@ public static BitmapDescriptor makeMarker(@NonNull Resources resources, final St final float emojiY = (markerDrawable.getIntrinsicHeight()/2.f) + (EMOJI_TEXT_SIZE/8f); canvas.drawText(emoji, emojiX, emojiY, paint); - var desc = BitmapDescriptorFactory.fromBitmap(bitmap); - BITMAP_CACHE.put(key, desc); - return desc; + BITMAP_CACHE.put(key, bitmap); + return bitmap; } /** * Create marker with some drawable resource in the middle of it (cached) */ - public static BitmapDescriptor makeMarker(@NonNull Resources resources, @DrawableRes int innerIcon, @ColorInt int markerColor, @ColorInt int iconColor) { + public static Bitmap makeMarker(@NonNull Resources resources, @DrawableRes int innerIcon, @ColorInt int markerColor, @ColorInt int iconColor) { final String key = String.format(Locale.ROOT, "%d-%d-%d", innerIcon, markerColor, iconColor); if (BITMAP_CACHE.containsKey(key)) { return BITMAP_CACHE.get(key); @@ -122,9 +118,8 @@ public static BitmapDescriptor makeMarker(@NonNull Resources resources, @Drawabl DrawableCompat.setTint(iconOnMarkerDrawable, iconColor); iconOnMarkerDrawable.draw(canvas); - var desc = BitmapDescriptorFactory.fromBitmap(bitmap); - BITMAP_CACHE.put(key, desc); - return desc; + BITMAP_CACHE.put(key, bitmap); + return bitmap; } private static void drawMarker(Canvas canvas, Drawable markerDrawable, @ColorInt int markerColor) { @@ -178,13 +173,17 @@ private static Bitmap drawShadow(Drawable markerDrawable) { var hardwareBuffer = Optional.ofNullable(image.getHardwareBuffer()).orElseThrow(() -> new RuntimeException("No HardwareBuffer")); var blurredBitmap = Optional.ofNullable(Bitmap.wrapHardwareBuffer(hardwareBuffer, null)).orElseThrow(() -> new RuntimeException("Create Bitmap failed")); + // 立即创建可变的 ARGB_8888 副本,避免 Hardware Bitmap 在跨线程使用时出现 BufferQueue 问题 + Bitmap softwareBitmap = blurredBitmap.copy(Bitmap.Config.ARGB_8888, true); + + // 清理 Hardware 资源 hardwareBuffer.close(); image.close(); imageReader.close(); renderNode.discardDisplayList(); hardwareRenderer.destroy(); - return blurredBitmap.copy(Bitmap.Config.ARGB_8888,true); + return softwareBitmap; } /** diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java index a60a6e6e..b3c84fd2 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/mydevices/DeviceListAdaptor.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.BiConsumer; import java.util.function.Consumer; import dev.wander.android.opentagviewer.R; @@ -31,6 +32,12 @@ public class DeviceListAdaptor extends RecyclerView.Adapter onDeviceClickCallback; + /** + * Receives the pressed row along with its device, because the caller shows a menu anchored + * to that row and therefore needs the view, not just the data. + */ + private final BiConsumer onDeviceLongClickCallback; + @Getter public static class ViewHolder extends RecyclerView.ViewHolder { private final FrameLayout container; @@ -55,11 +62,13 @@ public DeviceListAdaptor( @lombok.NonNull Resources resources, @lombok.NonNull List beaconInfo, @lombok.NonNull Map locations, - @lombok.NonNull Consumer onDeviceClickCallback) { + @lombok.NonNull Consumer onDeviceClickCallback, + @lombok.NonNull BiConsumer onDeviceLongClickCallback) { this.resources = resources; this.beaconInfo = beaconInfo; this.locations = locations; this.onDeviceClickCallback = onDeviceClickCallback; + this.onDeviceLongClickCallback = onDeviceLongClickCallback; } // Create new views (invoked by the layout manager) @@ -111,6 +120,12 @@ public void onBindViewHolder(ViewHolder viewHolder, final int position) { viewHolder.getContainer().setOnClickListener(v -> { this.onDeviceClickCallback.accept(beacon); }); + + viewHolder.getContainer().setOnLongClickListener(v -> { + this.onDeviceLongClickCallback.accept(v, beacon); + // Consumed, so the row does not also fire its normal click. + return true; + }); } // Return the size of your dataset (invoked by the layout manager) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java new file mode 100644 index 00000000..93682cd4 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/AmapApiKeyDialog.java @@ -0,0 +1,96 @@ +package dev.wander.android.opentagviewer.ui.settings; + +import static android.view.View.inflate; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; + +import java.util.Optional; +import java.util.function.Consumer; + +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.util.android.SigningInfoUtil; + +/** + * Prompt for the user's own AMap API key. + *
+ * Shared because AMap can be selected from two places - the first-run screen and Settings - + * and both need the same gate. No key ships with the app: AMap issues them per developer + * account, bound to a package name and signing fingerprint, and expects the key holder to + * be the app's operator. + */ +public final class AmapApiKeyDialog { + + private AmapApiKeyDialog() {} + + /** + * @param currentKey the key already stored, or null + * @param onResult receives the entered key, or null if the user cleared it. Not called + * if the dialog is cancelled, so a cancelled dialog leaves the current + * provider alone. + */ + public static void show( + final Activity activity, + final String currentKey, + final Consumer onResult) { + + View view = inflate(activity, R.layout.amap_api_key_input_dialog, null); + + final TextInputEditText keyInput = view.findViewById(R.id.amapApiKey); + final TextView details = view.findViewById(R.id.amap_registration_details); + final MaterialButton copyButton = view.findViewById(R.id.amap_copy_registration_details); + final MaterialButton guideButton = view.findViewById(R.id.amap_open_guide); + + keyInput.setText(Optional.ofNullable(currentKey).orElse("")); + + // Read from the installed package, so these stay correct for whichever build is in + // the user's hand rather than a documented value that goes stale when signing changes. + final String registrationDetails = SigningInfoUtil.getRegistrationDetails(activity); + details.setText(registrationDetails); + + copyButton.setOnClickListener(v -> { + var clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText("AMap registration details", registrationDetails)); + Toast.makeText(activity, R.string.amap_registration_details_copied, Toast.LENGTH_SHORT).show(); + }); + + // Registering a key means working through a Chinese-language console and knowing to + // supply the package name and fingerprint, so link the walkthrough. + guideButton.setOnClickListener(v -> { + var properties = PropertiesUtil.getProperties(activity.getAssets(), "app.properties"); + if (properties == null) { + return; + } + final String guideUrl = properties.getProperty("amapWikiPage"); + if (guideUrl == null || guideUrl.isBlank()) { + return; + } + activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(guideUrl))); + }); + + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.amap_api_key) + .setView(view) + .setPositiveButton(R.string.accept, (dialog, which) -> { + final String entered = Optional.ofNullable(keyInput.getText()) + .map(CharSequence::toString) + .map(String::trim) + .orElse(""); + onResult.accept(entered.isEmpty() ? null : entered); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java index f62f9393..50d22e7a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/settings/SharedMainSettingsManager.java @@ -32,6 +32,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import androidx.appcompat.app.AppCompatDelegate; import dev.wander.android.opentagviewer.R; import dev.wander.android.opentagviewer.db.repo.model.UserSettings; import dev.wander.android.opentagviewer.service.web.GithubRawUtilityFilesService; @@ -62,6 +63,7 @@ public class SharedMainSettingsManager { private ArrayAdapter shownLocalesAdapter = null; private final Consumer onLanguageSelectedCallback; + private final Consumer onMapProviderSelectedCallback; private final Set urlOptions = new HashSet<>(); @@ -75,6 +77,7 @@ public class SharedMainSettingsManager { public SharedMainSettingsManager( @NonNull AppCompatActivity context, @NonNull Consumer onLanguageSelected, + @NonNull Consumer onMapProviderSelected, @NonNull Consumer onNewAnisetteUrlSelected, @NonNull GithubRawUtilityFilesService github, @NonNull UserSettings currentUserSettings, @@ -82,6 +85,7 @@ public SharedMainSettingsManager( ) { this.context = context; this.onLanguageSelectedCallback = onLanguageSelected; + this.onMapProviderSelectedCallback = onMapProviderSelected; this.onNewAnisetteUrlSelectedCallback = onNewAnisetteUrlSelected; this.github = github; this.currentUserSettings = currentUserSettings; @@ -127,11 +131,11 @@ public void setupLanguageSwitchField() { } private void setupCurrentLocalePretty() { - final String currentLocale = Locale.getDefault().getLanguage(); + final String currentLocale = this.getCurrentLocaleTag(); AppAutoCompleteTextView languageDropdown = this.context.findViewById(R.id.languageSelectDropdown); this.mappedLocales.entrySet().stream() - .filter(kvp -> kvp.getValue().equals(currentLocale)) + .filter(kvp -> this.localeTagMatches(kvp.getValue(), currentLocale)) .findFirst() .map(Map.Entry::getKey) .ifPresent(option -> languageDropdown.setText(option, false)); @@ -144,6 +148,39 @@ private void setupCurrentLocalePretty() { handler.postDelayed(languageDropdown::clearFocus, 10); } + public void setupMapProviderField() { + AppAutoCompleteTextView mapProviderDropdown = this.context.findViewById(R.id.mapProviderSelectDropdown); + + String[] providerLabels = new String[] { + this.context.getString(R.string.map_provider_google), + this.context.getString(R.string.map_provider_amap) + }; + + mapProviderDropdown.setSimpleItems(providerLabels); + this.setupCurrentMapProviderPretty(); + + mapProviderDropdown.setOnItemClickListener((parent, view, position, id) -> { + final String selectedProvider = position == 1 ? "amap" : "google"; + final String selectedLabel = parent.getItemAtPosition(position).toString(); + mapProviderDropdown.setText(selectedLabel, false); + mapProviderDropdown.clearFocus(); + this.onMapProviderSelectedCallback.accept(selectedProvider); + }); + } + + private void setupCurrentMapProviderPretty() { + AppAutoCompleteTextView mapProviderDropdown = this.context.findViewById(R.id.mapProviderSelectDropdown); + if (mapProviderDropdown == null) { + return; + } + + final String provider = this.currentUserSettings.getMapProvider(); + final int labelRes = "amap".equals(provider) + ? R.string.map_provider_amap + : R.string.map_provider_google; + mapProviderDropdown.setText(this.context.getString(labelRes), false); + } + public void setupAnisetteServerUrlField() { TextInputLayout urlTextInputContainer = this.context.findViewById(R.id.anisetteServerUrlContainer); MaterialAutoCompleteTextView urlTextInput = this.context.findViewById(R.id.anisetteServerUrl); @@ -271,13 +308,38 @@ public void setAnisetteTextFieldError(final int stringId, Object... formatArgs) private String getPrettyLanguageName(final String languageId) { var res = this.context.getResources(); return res.getString(res.getIdentifier( - "lang_" + languageId, + LocaleConfigUtil.toLocaleLabelResourceName(languageId), "string", this.context.getPackageName())); } public void handleOnResume() { this.setupCurrentLocalePretty(); + this.setupCurrentMapProviderPretty(); + } + + private String getCurrentLocaleTag() { + String configuredTag = this.currentUserSettings.getLanguage(); + if (configuredTag != null && !configuredTag.isBlank()) { + return configuredTag; + } + + String appLocaleTags = AppCompatDelegate.getApplicationLocales().toLanguageTags(); + if (appLocaleTags != null && !appLocaleTags.isBlank()) { + return appLocaleTags.split(",")[0]; + } + + return Locale.getDefault().toLanguageTag(); + } + + private boolean localeTagMatches(String supportedLocaleTag, String currentLocaleTag) { + if (supportedLocaleTag == null || currentLocaleTag == null) { + return false; + } + if (supportedLocaleTag.equalsIgnoreCase(currentLocaleTag)) { + return true; + } + return currentLocaleTag.toLowerCase(Locale.ROOT).startsWith(supportedLocaleTag.toLowerCase(Locale.ROOT) + "-"); } public enum ANISETTE_TEST_STATUS { diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java new file mode 100644 index 00000000..1449a70d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/FlowLayout.java @@ -0,0 +1,155 @@ +package dev.wander.android.opentagviewer.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lays children out left to right, wrapping to a new row when the current one runs out of + * width, and reports the height that actually took. + *
+ * A GridLayout with a fixed column count would misuse the space on anything that is not the + * screen it was tuned for - too cramped on a tablet, overflowing on a small phone. Here the + * number of items per row falls out of the measured width instead. + *
+ * Rows are centred horizontally, which reads better than a ragged last row for a set of + * equally sized items such as avatars. + */ +public class FlowLayout extends ViewGroup { + + private final List> rows = new ArrayList<>(); + private final List rowHeights = new ArrayList<>(); + private final List rowWidths = new ArrayList<>(); + + public FlowLayout(Context context) { + super(context); + } + + public FlowLayout(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public FlowLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new MarginLayoutParams(getContext(), attrs); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + } + + @Override + protected LayoutParams generateLayoutParams(LayoutParams p) { + return new MarginLayoutParams(p); + } + + @Override + protected boolean checkLayoutParams(LayoutParams p) { + return p instanceof MarginLayoutParams; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + this.rows.clear(); + this.rowHeights.clear(); + this.rowWidths.clear(); + + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) + - getPaddingLeft() - getPaddingRight(); + + // UNSPECIFIED means nothing is constraining us, so wrapping would never trigger and + // every child would end up on one row. Treat it as a single row on purpose. + final boolean canWrap = widthMode != MeasureSpec.UNSPECIFIED; + + List row = new ArrayList<>(); + int rowWidth = 0; + int rowHeight = 0; + int totalHeight = 0; + int widestRow = 0; + + for (int i = 0; i < getChildCount(); i++) { + final View child = getChildAt(i); + if (child.getVisibility() == GONE) { + continue; + } + + measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0); + + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + final int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin; + final int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin; + + if (canWrap && !row.isEmpty() && rowWidth + childWidth > maxWidth) { + this.rows.add(row); + this.rowHeights.add(rowHeight); + this.rowWidths.add(rowWidth); + totalHeight += rowHeight; + widestRow = Math.max(widestRow, rowWidth); + + row = new ArrayList<>(); + rowWidth = 0; + rowHeight = 0; + } + + row.add(child); + rowWidth += childWidth; + rowHeight = Math.max(rowHeight, childHeight); + } + + if (!row.isEmpty()) { + this.rows.add(row); + this.rowHeights.add(rowHeight); + this.rowWidths.add(rowWidth); + totalHeight += rowHeight; + widestRow = Math.max(widestRow, rowWidth); + } + + final int measuredWidth = widthMode == MeasureSpec.EXACTLY + ? MeasureSpec.getSize(widthMeasureSpec) + : widestRow + getPaddingLeft() + getPaddingRight(); + + setMeasuredDimension( + resolveSize(measuredWidth, widthMeasureSpec), + resolveSize(totalHeight + getPaddingTop() + getPaddingBottom(), heightMeasureSpec)); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int contentWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); + int y = getPaddingTop(); + + for (int rowIndex = 0; rowIndex < this.rows.size(); rowIndex++) { + final List row = this.rows.get(rowIndex); + int x = getPaddingLeft() + ((contentWidth - this.rowWidths.get(rowIndex)) / 2); + + for (View child : row) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + + final int childLeft = x + lp.leftMargin; + final int childTop = y + lp.topMargin; + + child.layout( + childLeft, + childTop, + childLeft + child.getMeasuredWidth(), + childTop + child.getMeasuredHeight()); + + x += child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin; + } + + y += this.rowHeights.get(rowIndex); + } + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/SkeletonView.java b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/SkeletonView.java new file mode 100644 index 00000000..6e0ad401 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/ui/widget/SkeletonView.java @@ -0,0 +1,95 @@ +package dev.wander.android.opentagviewer.ui.widget; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import androidx.annotation.Nullable; + +/** + * A placeholder bar that pulses while the real content is still loading. + *
+ * Screens here fill themselves from RxJava chains backed by DataStore and Room, so their + * text views start empty and get their content a frame or several later. Left alone that + * reads as the page glitching: labels appear from nowhere and the surrounding card resizes + * under the reader. Reserving the space up front and animating it removes both the jump and + * the ambiguity about whether anything is happening. + *
+ * Self-driving: it animates whenever it is attached and visible, and stops when detached or + * hidden, so callers only toggle visibility. An animator left running on a detached view is + * a leak that also quietly burns frames, which is why this is a view rather than a helper + * someone has to remember to stop. + */ +public class SkeletonView extends View { + + private static final long PULSE_DURATION_MS = 900; + private static final float MIN_ALPHA = 0.25f; + private static final float MAX_ALPHA = 0.65f; + + @Nullable + private ValueAnimator animator; + + public SkeletonView(Context context) { + super(context); + } + + public SkeletonView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public SkeletonView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + this.startPulsing(); + } + + @Override + protected void onDetachedFromWindow() { + this.stopPulsing(); + super.onDetachedFromWindow(); + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + + if (visibility == VISIBLE && isAttachedToWindow()) { + this.startPulsing(); + } else { + this.stopPulsing(); + } + } + + private void startPulsing() { + if (this.animator != null || getVisibility() != VISIBLE) { + return; + } + + ValueAnimator pulse = ValueAnimator.ofFloat(MIN_ALPHA, MAX_ALPHA); + pulse.setDuration(PULSE_DURATION_MS); + pulse.setRepeatCount(ValueAnimator.INFINITE); + pulse.setRepeatMode(ValueAnimator.REVERSE); + pulse.setInterpolator(new AccelerateDecelerateInterpolator()); + pulse.addUpdateListener(a -> setAlpha((float) a.getAnimatedValue())); + pulse.start(); + + this.animator = pulse; + } + + private void stopPulsing() { + if (this.animator == null) { + return; + } + + this.animator.cancel(); + this.animator = null; + // Otherwise it keeps whatever alpha the animation was cancelled on. + setAlpha(MAX_ALPHA); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java index cc378d81..f6aea10f 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/LocaleConfigUtil.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Optional; import dev.wander.android.opentagviewer.R; @@ -67,4 +68,13 @@ private static void handleStartTag(XmlResourceParser xml, List out) { out.add(name); } } + + public static String toLocaleLabelResourceName(String localeTag) { + if (localeTag == null || localeTag.isBlank()) { + return "lang_en"; + } + return "lang_" + localeTag + .replace('-', '_') + .toLowerCase(Locale.ROOT); + } } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java new file mode 100644 index 00000000..49eeda6d --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/android/SigningInfoUtil.java @@ -0,0 +1,94 @@ +package dev.wander.android.opentagviewer.util.android; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; +import android.os.Build; +import android.util.Log; + +import java.security.MessageDigest; +import java.util.Locale; +import java.util.Optional; + +/** + * Reports the running app's package name and signing certificate fingerprints. + *
+ * AMap issues Android API keys bound to a package name plus a SHA-1 signing fingerprint, + * so anyone registering a key has to supply both. Reading them from the installed package + * at runtime means the values are always right for the build in the user's hand - debug or + * release, official or self-built - rather than a static value in a README that goes stale + * the moment a signing key changes. + */ +public final class SigningInfoUtil { + private static final String TAG = SigningInfoUtil.class.getSimpleName(); + + private SigningInfoUtil() {} + + /** + * @return the SHA-1 of the certificate this app was signed with, colon-separated and + * upper-case (the format AMap's console expects), or empty if it cannot be read. + */ + public static Optional getSha1Fingerprint(final Context context) { + return getFingerprint(context, "SHA-1"); + } + + public static Optional getSha256Fingerprint(final Context context) { + return getFingerprint(context, "SHA-256"); + } + + private static Optional getFingerprint(final Context context, final String algorithm) { + try { + final Signature[] signatures = getSignatures(context); + if (signatures == null || signatures.length == 0) { + Log.w(TAG, "No signatures found for this package"); + return Optional.empty(); + } + + // The first signer is the one certificate authorities and SDK consoles key off. + MessageDigest digest = MessageDigest.getInstance(algorithm); + byte[] hash = digest.digest(signatures[0].toByteArray()); + + StringBuilder out = new StringBuilder(hash.length * 3); + for (int i = 0; i < hash.length; i++) { + if (i > 0) { + out.append(':'); + } + out.append(String.format(Locale.ROOT, "%02X", hash[i])); + } + return Optional.of(out.toString()); + + } catch (Exception e) { + Log.e(TAG, "Failed to compute " + algorithm + " signing fingerprint", e); + return Optional.empty(); + } + } + + @SuppressWarnings("deprecation") // GET_SIGNATURES is the only option below API 28 + private static Signature[] getSignatures(final Context context) throws PackageManager.NameNotFoundException { + final PackageManager packageManager = context.getPackageManager(); + final String packageName = context.getPackageName(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageInfo info = packageManager.getPackageInfo( + packageName, PackageManager.GET_SIGNING_CERTIFICATES); + if (info.signingInfo == null) { + return null; + } + return info.signingInfo.hasMultipleSigners() + ? info.signingInfo.getApkContentsSigners() + : info.signingInfo.getSigningCertificateHistory(); + } + + PackageInfo info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES); + return info.signatures; + } + + /** + * The block a user pastes into a map provider's console when registering a key. + */ + public static String getRegistrationDetails(final Context context) { + return "Package Name: " + context.getPackageName() + "\n" + + "SHA1: " + getSha1Fingerprint(context).orElse("(unavailable)"); + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java index 3abdbc9c..18f1207d 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/parse/AppleZipImporterUtil.java @@ -54,7 +54,13 @@ public class AppleZipImporterUtil { enum FILE_TYPE { EXPORT_INFO, OWNED_BEACON, - BEACON_NAMING_RECORD; + BEACON_NAMING_RECORD, + /** + * Optional. Carries the rolling-key alignment macOS last observed for an accessory, + * which lets FindMy 0.9.x start fetching from the right key index instead of + * searching the tag's entire history. Absent from exports made before format 0.0.2. + */ + KEY_ALIGNMENT_RECORD; } private static final String TAG = AppleZipImporterUtil.class.getSimpleName(); @@ -64,7 +70,10 @@ enum FILE_TYPE { private static final Map MATCHERS = Map.of( FILE_TYPE.EXPORT_INFO, Pattern.compile("^OPENTAGVIEWER\\.yml$"), FILE_TYPE.OWNED_BEACON, Pattern.compile("^OwnedBeacons/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$"), - FILE_TYPE.BEACON_NAMING_RECORD, Pattern.compile("^BeaconNamingRecord/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$") + FILE_TYPE.BEACON_NAMING_RECORD, Pattern.compile("^BeaconNamingRecord/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$"), + // Same nesting as BeaconNamingRecord: /. The wizard + // rewrites the .record extension macOS uses to .plist on export. + FILE_TYPE.KEY_ALIGNMENT_RECORD, Pattern.compile("^KeyAlignmentRecords/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})/([0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})\\.plist$") ); private static final String X_PATH_TO_CLOUDKIT_METADATA = "/plist/dict/key[.='cloudKitMetadata']/following-sibling::data[1]"; @@ -80,6 +89,9 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio String openTagViewerYaml = null; Map ownedBeacons = new HashMap<>(); Map> beaconNamingRecords = new HashMap<>(); + // Optional per accessory: absent for exports made before format 0.0.2, and for + // accessories macOS has never observed a key index for. + Map keyAlignmentRecords = new HashMap<>(); try (ZipInputStream zipInput = new ZipInputStream( this.appContext.getContentResolver() @@ -131,6 +143,10 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio case BEACON_NAMING_RECORD: processBeaconNamingRecord(typeAndRegexGroups.second, fileContent, beaconNamingRecords); break; + case KEY_ALIGNMENT_RECORD: + // Keyed by the parent directory, which is the accessory id. + keyAlignmentRecords.put(typeAndRegexGroups.second.get(1), fileContent); + break; } } } catch (IOException e) { @@ -144,7 +160,8 @@ public ImportData extractZip(@NonNull Uri zipFileUri) throws ZipImporterExceptio return convert( openTagViewerYaml, ownedBeacons, - beaconNamingRecords + beaconNamingRecords, + keyAlignmentRecords ); } @@ -382,10 +399,11 @@ private static Pair> getAllowedFileType(final String fil private static ImportData convert( final String importInfo, final Map ownedBeacons, - final Map> beaconNamingRecords) { + final Map> beaconNamingRecords, + final Map keyAlignmentRecords) { try { Import anImport = parseImportInfo(importInfo); - List beacons = makeOwnedBeacons(ownedBeacons, anImport.version); + List beacons = makeOwnedBeacons(ownedBeacons, keyAlignmentRecords, anImport.version); List records = makeBeaconNamingRecords(beaconNamingRecords, anImport.version); return new ImportData( @@ -410,17 +428,40 @@ private static Import parseImportInfo(final String importInfo) throws JsonProces .build(); } - private static List makeOwnedBeacons(final Map ownedBeacons, final String version) { + private static List makeOwnedBeacons( + final Map ownedBeacons, + final Map keyAlignmentRecords, + final String version) { return ownedBeacons.entrySet().stream() .map(kvp -> OwnedBeacon.builder() .id(kvp.getKey()) .importId(null) // TODO: fill on create Import .version(version) .content(kvp.getValue()) + // Null when the export predates format 0.0.2 or macOS had no + // alignment for this accessory; conversion handles that. + .alignmentPlist(keyAlignmentRecords.get(kvp.getKey())) + // Eagerly convert plist → JSON for FindMy 0.9.x; nullable on failure + // (lazy backfill in BeaconRepository will retry on first fetch). + .accessoryJson(plistToAccessoryJsonOrNull( + kvp.getValue(), keyAlignmentRecords.get(kvp.getKey()))) .build()) .collect(Collectors.toList()); } + private static String plistToAccessoryJsonOrNull( + final String plistXml, final String alignmentPlistXml) { + try { + var py = com.chaquo.python.Python.getInstance(); + var module = py.getModule("main"); + var result = module.callAttr("convertPlistToJson", plistXml, alignmentPlistXml); + return result == null ? null : result.toString(); + } catch (Exception e) { + Log.w(TAG, "convertPlistToJson at import time failed; will backfill lazily on first fetch", e); + return null; + } + } + private static List makeBeaconNamingRecords(final Map> beaconNamingRecords, final String version) { return beaconNamingRecords.entrySet().stream() .map(kvp -> BeaconNamingRecord.builder() diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/BeaconLocationHistory.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/BeaconLocationHistory.java new file mode 100644 index 00000000..99d2b9f8 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/BeaconLocationHistory.java @@ -0,0 +1,91 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import dev.wander.android.opentagviewer.data.model.BeaconLocationReport; +import dev.wander.android.opentagviewer.db.util.BeaconCombinerUtil; + +/** + * The location history the map is currently holding, keyed by beacon. + *
+ * Free of Android types so the rules can be tested. Two of them have cost real debugging time: + *

    + *
  • A beacon with no location cannot be drawn. It gets no marker and no card, so it + * vanishes from the UI rather than appearing as an error. When a bug cancelled two of + * three accessory fetches, this rule is what turned it into "I only have 1 card".
  • + *
  • The newest report is the last element. Five separate callers read + * {@code locations.get(size - 1)}, which is only correct because the merge sorts + * ascending by timestamp. Nothing enforced that, and a re-sort would have broken every + * one of them at once - showing a stale position, not an empty one.
  • + *
+ * Backed by a concurrent map: fetch results land on RxJava's IO and computation schedulers + * while the UI reads on the main thread. + *
+ * Beacon metadata deliberately stays in {@code MapsActivity}: it carries geocoding results and + * a map position, both Android types, and dragging those in here would make this untestable + * for no gain. + */ +public final class BeaconLocationHistory { + + private final Map> byBeaconId = new ConcurrentHashMap<>(); + + /** + * Merges newly fetched reports into the history for one beacon, de-duplicating and sorting + * oldest-first. + *
+ * A refresh asks for a window that overlaps what is already held - on purpose, so nothing + * is missed at the boundary - so the same report arrives repeatedly and has to be + * collapsed by content rather than appended. + * + * @return the size of the merged history, for logging + */ + public int merge(final String beaconId, final List reports) { + final List existing = + this.byBeaconId.getOrDefault(beaconId, Collections.emptyList()); + + // Sorted even on the first fetch. Storing Apple's response as-is - which is what this + // did - left the ordering up to whatever the server happened to return, and every + // caller reads the last element as the newest. An unsorted first response therefore + // put a stale position on the map, silently and only for freshly imported tags. + final List merged = + BeaconCombinerUtil.combineAndSort(beaconId, existing, reports); + this.byBeaconId.put(beaconId, merged); + return merged.size(); + } + + /** + * The most recent report for a beacon, or empty if it has none. + *
+ * Empty is the ordinary case for a tag Apple has not answered for yet, not a failure. It + * simply cannot be placed on a map. + */ + public Optional lastLocationOf(final String beaconId) { + final List reports = this.byBeaconId.get(beaconId); + if (reports == null || reports.isEmpty()) { + return Optional.empty(); + } + // Last, not first: merge() sorts ascending by timestamp. + return Optional.of(reports.get(reports.size() - 1)); + } + + /** Whether this beacon can be drawn at all: no location means no marker and no card. */ + public boolean isDrawable(final String beaconId) { + return this.lastLocationOf(beaconId).isPresent(); + } + + public List of(final String beaconId) { + return this.byBeaconId.getOrDefault(beaconId, Collections.emptyList()); + } + + public int sizeOf(final String beaconId) { + return this.of(beaconId).size(); + } + + public boolean knows(final String beaconId) { + return this.byBeaconId.containsKey(beaconId); + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/LongFetchBannerState.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/LongFetchBannerState.java new file mode 100644 index 00000000..185d060c --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/LongFetchBannerState.java @@ -0,0 +1,79 @@ +package dev.wander.android.opentagviewer.util.rx; + +/** + * Tracks whether the "still locating your tags" banner should be up, and what it should count. + *
+ * The banner exists because a tag whose export carried no {@code KeyAlignmentRecord} starts at + * index 0 from its pairing date, so its first fetch searches the tag's whole life - tens of + * thousands of key indices, at roughly 290 per request. That is minutes during which nothing + * changes on screen, and it is indistinguishable from a hang. It also matters that the user + * does not walk away mid-batch. + *
+ * Only the bookkeeping lives here; the delayed show, the {@code TextView} and the string + * resources stay in {@code MapsActivity}. Fetches overlap - a manual refresh can start while + * the periodic one is running - and getting the counting wrong shows a banner that never goes + * away, or hides one that should still be up. Neither throws, so neither is visible to + * anything but a person watching the screen. + *
+ * Not synchronised: every method is called from the banner handler, i.e. the main thread. + */ +public final class LongFetchBannerState { + + private int fetchesInFlight = 0; + private int done = 0; + private int total = 0; + + /** + * @return true if this is the first fetch of a batch, and the caller should start the + * delay after which the banner appears + */ + public boolean fetchStarted() { + final boolean first = this.fetchesInFlight == 0; + this.fetchesInFlight++; + if (first) { + // Clear the previous run's counts, so a stale "3 of 5" cannot flash up before the + // new batch reports its own total. + this.done = 0; + this.total = 0; + } + return first; + } + + /** + * @return true if nothing is left in flight, and the caller should cancel the pending show + * and hide the banner + */ + public boolean fetchFinished() { + // Floored rather than allowed to go negative: an unbalanced finish would otherwise + // leave the count below zero, and the next start would not be seen as the first - so + // the banner would never appear again for the rest of the session. + this.fetchesInFlight = Math.max(0, this.fetchesInFlight - 1); + return this.fetchesInFlight == 0; + } + + public void setProgress(final int done, final int total) { + this.done = done; + this.total = total; + } + + public boolean isFetching() { + return this.fetchesInFlight > 0; + } + + /** Whether to show "(2 of 5)" at all. "1 of 1" tells the user nothing they cannot see. */ + public boolean hasCount() { + return this.total > 1; + } + + /** + * The one-based position to display: the accessory being worked on, not the count of + * finished ones. Clamped so the last one cannot read "6 of 5" once it completes. + */ + public int displayedPosition() { + return Math.min(this.done + 1, this.total); + } + + public int total() { + return this.total; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/MarkerFocus.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/MarkerFocus.java new file mode 100644 index 00000000..0d7984ae --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/MarkerFocus.java @@ -0,0 +1,90 @@ +package dev.wander.android.opentagviewer.util.rx; + +import androidx.annotation.Nullable; + +/** + * Keeps the selected tag's marker drawn above the others. + *
+ * Tags kept together - a wallet, keys and a bag by the front door - resolve to positions metres + * apart, which at anything but the closest zoom is a single pile of markers. The map draws + * whichever it likes on top, so without raising the selected one, tapping a card moves the + * camera to a marker that stays hidden underneath another and the tap looks ignored. + *
+ * This existed before the map provider abstraction, as {@code Marker.setZIndex} on the Google + * marker directly, and was dropped when markers moved behind {@code IMapProvider} - + * {@code MapPolyline} kept its zIndex, {@code MapMarker} never gained one. It went unnoticed + * because nothing failed: the constants and the field it used stayed behind as unreferenced + * fossils, so the code still looked present. + *
+ * Free of Android types so the ordering can be tested. Called from the main thread only. + */ +public final class MarkerFocus { + + public static final float ZINDEX_DEFAULT = 0.0f; + public static final float ZINDEX_TOP = 10.0f; + + /** The map, reduced to what focusing needs. */ + public interface Markers { + /** @return the marker id currently drawn for this beacon, or null if there is none */ + @Nullable + String markerIdFor(String beaconId); + + void setZIndex(String markerId, float zIndex); + } + + private final Markers markers; + private String focusedBeaconId = null; + + public MarkerFocus(final Markers markers) { + this.markers = markers; + } + + /** + * Raises this beacon's marker and lowers whichever was raised before. + *
+ * Does nothing at all if the beacon has no marker yet, which happens when a card is + * selected during the first draw. Lowering the previous one first would leave the pile + * with nothing raised. + */ + public void focus(final String beaconId) { + if (beaconId == null || beaconId.equals(this.focusedBeaconId)) { + return; + } + + final String markerId = this.markers.markerIdFor(beaconId); + if (markerId == null) { + return; + } + + if (this.focusedBeaconId != null) { + final String previousMarkerId = this.markers.markerIdFor(this.focusedBeaconId); + if (previousMarkerId != null) { + this.markers.setZIndex(previousMarkerId, ZINDEX_DEFAULT); + } + } + + this.markers.setZIndex(markerId, ZINDEX_TOP); + this.focusedBeaconId = beaconId; + } + + /** + * The draw order a freshly built marker should carry. + *
+ * Every refresh removes and re-adds the markers, so the focused one has to be rebuilt + * raised. Raising it only on selection would let the next refresh drop it back under the + * pile without the user touching anything. + */ + public float zIndexFor(final String beaconId) { + return beaconId != null && beaconId.equals(this.focusedBeaconId) ? ZINDEX_TOP : ZINDEX_DEFAULT; + } + + @Nullable + public String focusedBeaconId() { + return this.focusedBeaconId; + } + + /** Forgets the focus, for when the markers are cleared out from under it. */ + public void clear() { + this.focusedBeaconId = null; + } +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RefreshPolicy.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RefreshPolicy.java new file mode 100644 index 00000000..eb61aeaa --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RefreshPolicy.java @@ -0,0 +1,134 @@ +package dev.wander.android.opentagviewer.util.rx; + +/** + * Decides when the map may refresh, and how much history to ask for when it does. + *
+ * Pure and free of Android types so it can be tested: the rules live in a scheduled callback + * inside {@code MapsActivity}, where every one of them was previously only observable by + * watching the app for a minute and guessing. Both bugs this encodes were found that way. + *
+ * Holds the time of the last successful fetch, and nothing else. The caller supplies the + * current time rather than the class reading a clock, because the fetch has to capture one + * timestamp and use it for both the request window and the bookkeeping afterwards - taking a + * fresh reading at the end would leave a gap in history the width of the fetch, which for an + * unaligned tag is minutes. + */ +public final class RefreshPolicy { + + /** Why a refresh did or did not happen. The reason is logged, so it has to be readable. */ + public enum Decision { + REFRESH(true, "performing the scheduled refresh"), + SERVICE_NOT_READY(false, "the Apple service is not initialised yet"), + INITIAL_FETCH_INCOMPLETE(false, "the first fetch has not finished yet"), + FETCH_IN_PROGRESS(false, "a fetch is still in progress"), + TOO_SOON(false, "not enough time has passed since the last fetch"); + + private final boolean shouldRefresh; + private final String reason; + + Decision(final boolean shouldRefresh, final String reason) { + this.shouldRefresh = shouldRefresh; + this.reason = reason; + } + + public boolean shouldRefresh() { + return this.shouldRefresh; + } + + public String reason() { + return this.reason; + } + } + + private final long minIntervalMillis; + private final int maxHoursBack; + + /** Volatile: written from the fetch's thread, read by the scheduler on the main thread. */ + private volatile long lastFetchAt = 0L; + + public RefreshPolicy(final long minIntervalMillis, final int maxHoursBack) { + this.minIntervalMillis = minIntervalMillis; + this.maxHoursBack = maxHoursBack; + } + + /** + * Whether the periodic tick should refresh now. + *
+ * The in-progress check is the important one. Calls into Python are serialised, and a + * first fetch for an accessory with no key alignment record can run for minutes, so + * without it a tick does not skip - it blocks on the lock. The backlog then grows by one + * every minute for the whole fetch and fires all at once when it finally clears. + */ + public Decision decide( + final long now, + final boolean serviceReady, + final boolean initialFetchComplete, + final boolean fetchInProgress) { + + if (!serviceReady) { + return Decision.SERVICE_NOT_READY; + } + if (!initialFetchComplete) { + return Decision.INITIAL_FETCH_INCOMPLETE; + } + if (fetchInProgress) { + return Decision.FETCH_IN_PROGRESS; + } + if (now < this.lastFetchAt + this.minIntervalMillis) { + return Decision.TOO_SOON; + } + return Decision.REFRESH; + } + + /** + * How many hours of history to request: enough to cover the gap since the last successful + * fetch, capped at {@code maxHoursBack}. + *
+ * Never returns 0. Asking Apple for zero hours returns nothing, and the caller cannot + * tell that apart from a tag that has genuinely not been seen - it would look like the + * refresh worked and the tag had vanished. Reachable whenever two fetches land in the + * same millisecond, which the manual refresh button allows. + */ + public int hoursToGoBack(final long now) { + final long elapsed = Math.max(0L, now - this.lastFetchAt); + final long hours = (elapsed + ONE_HOUR_MS - 1) / ONE_HOUR_MS; // ceiling division + return (int) Math.min(Math.max(1L, hours), this.maxHoursBack); + } + + /** + * Records a successful fetch. + * + * @param at the time the fetch started, not the time it finished + */ + public void markFetched(final long at) { + this.lastFetchAt = at; + } + + public long lastFetchAt() { + return this.lastFetchAt; + } + + /** False until the first successful fetch. Distinguishes "never" from "a long time ago". */ + public boolean hasEverFetched() { + return this.lastFetchAt > 0L; + } + + public long millisSinceLastFetch(final long now) { + return now - this.lastFetchAt; + } + + /** + * How long since the last fetch, for logging. + *
+ * Says so plainly when there has not been one, rather than reporting the interval since + * the epoch - which reads as a 56-year-old fetch and sends the reader looking for a clock + * bug that is not there. + */ + public String describeTimeSinceLastFetch(final long now) { + return this.hasEverFetched() + ? this.millisSinceLastFetch(now) + " ms since the last fetch" + : "no successful fetch yet"; + } + + private static final long ONE_HOUR_MS = 1000L * 60L * 60L; +} diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RxFlows.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RxFlows.java new file mode 100644 index 00000000..322cff5e --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/RxFlows.java @@ -0,0 +1,101 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.functions.BiConsumer; +import io.reactivex.rxjava3.functions.Function; + +/** + * Stream compositions that the app depends on and that are easy to get subtly wrong. + *
+ * Both methods here exist because the composition, not the work, was the bug. They are kept + * free of Android types so they can be tested on the JVM: the originals lived inside + * {@code MapsActivity}, where nothing could reach them, and the failure they produced was a + * missing card rather than an exception - no crash, no log, no test. + * + * @see dev.wander.android.opentagviewer.MapsActivity + */ +public final class RxFlows { + + private RxFlows() { + } + + /** + * Runs every stream to completion, concurrently, and then runs {@code then} once. + *
+ * Written to replace an {@link Observable#zip} fork-join. zip completes as soon as its + * shortest source does and disposes the rest, so pairing a stream that emits once + * per accessory with one that emits a single parsed list cancelled every accessory after + * the first - silently, because a disposal is not an error. Importing three tags fetched + * one. Merging cannot truncate a source; it ends when all of them have. + *
+ * Emissions are dropped, so any per-item work belongs in a {@code doOnNext} on the stream + * that is passed in. If any stream fails, {@code then} does not run. + * + * @param then run once, after every stream has completed + * @param streams run concurrently; their values are ignored + */ + public static Completable allThen(final Completable then, final Observable... streams) { + final Completable[] parts = new Completable[streams.length]; + for (int i = 0; i < streams.length; i++) { + parts[i] = streams[i].ignoreElements(); + } + return Completable.mergeArray(parts).andThen(then); + } + + /** + * Runs {@code work} for each item, one at a time and in order, carrying on if one fails. + *
+ * Sequential on purpose. FindMy.py's synchronous account drives a single asyncio event + * loop, and calls into Python are serialised anyway, so {@code concatMap} makes explicit + * what would otherwise be threads queued on a lock. + *
+ * Each result is emitted as it arrives rather than batched at the end. That matters + * because the caller persists them: handing Python the whole list meant one dict at the + * very end, so quitting part-way through discarded the updated key alignment for every + * accessory - including ones that had already resolved - and the next launch searched the + * same tens of thousands of key indices again. + *
+ * A failure is reported to {@code onError} and the item is skipped. One tag that Apple + * will not answer for must not take the rest of the batch with it. + * + * @param items processed in list order + * @param work the request for one item; may emit any number of results + * @param progress called with (completed, total) before starting and after each item + * @param onError called with the item and its failure; the batch continues regardless + */ + public static Observable oneAtATime( + final List items, + final Function> work, + final BiConsumer progress, + final BiConsumer onError) { + + final int total = items.size(); + final int[] completed = {0}; + + return Observable.defer(() -> { + // Inside defer so a re-subscribe restarts the count rather than resuming + // from where the last subscription left off. + completed[0] = 0; + progress.accept(0, total); + + return Observable.fromIterable(items).concatMap(item -> + asObservable(work, item) + .doOnError(error -> onError.accept(item, error)) + .onErrorResumeNext(__ -> Observable.empty()) + .doOnComplete(() -> progress.accept(++completed[0], total))); + }); + } + + /** Keeps a throwing {@code work} function from breaking the chain before it starts. */ + private static Observable asObservable( + final Function> work, final T item) { + try { + return work.apply(item); + } catch (Throwable error) { + return Observable.error(error); + } + } +} diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 5b28c9f8..5840ba4d 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,5 +1,7 @@ from enum import Enum +from typing import Any import json +import time import traceback from datetime import datetime, timezone from io import BytesIO @@ -12,7 +14,7 @@ AppleAccount, LoginState, SmsSecondFactorMethod, - TrustedDeviceSecondFactorMethod + TrustedDeviceSecondFactorMethod, ) from findmy.reports.twofactor import ( SyncSecondFactorMethod @@ -25,7 +27,7 @@ class TwoFactorMethods(Enum): PHONE = 2 -def _toUnixEpochMs(dt: datetime) -> int: +def _toUnixEpochMs(dt: datetime | None) -> int | None: """ Convert datetime to unix epoch (milliseconds) """ @@ -59,7 +61,7 @@ def foo(arg: str): } -def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: +def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict | None: """ Extract some extra information from within the plist file `cloudKitMetadata` node (that is followed by a `` element containing base64) @@ -70,8 +72,10 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: ### More info: - The most popular java plist parser, the google java [dd-plist](https://mvnrepository.com/artifact/com.googlecode.plist/dd-plist) library, - [does not currently support the `NSKeyedArchiver` plist format](https://github.com/3breadt/dd-plist/issues/70) (at the time of writing). + The most popular java plist parser, the google java + [dd-plist](https://mvnrepository.com/artifact/com.googlecode.plist/dd-plist) library, + [does not currently support the `NSKeyedArchiver` plist format](https://github.com/3breadt/dd-plist/issues/70) + (at the time of writing). However somebody has managed to create a parser in python: https://github.com/avibrazil/NSKeyedUnArchiver @@ -79,7 +83,8 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: we will extract it using python via this nice library and pass the needed data back to Java See: - - https://www.mac4n6.com/blog/2016/1/1/manual-analysis-of-nskeyedarchiver-formatted-plist-files-a-review-of-the-new-os-x-1011-recent-items + - https://www.mac4n6.com/blog/2016/1/1/ + manual-analysis-of-nskeyedarchiver-formatted-plist-files-a-review-of-the-new-os-x-1011-recent-items - https://github.com/malmeloo/FindMy.py/issues/31#issuecomment-2628072362 - https://github.com/3breadt/dd-plist/issues/70 @@ -90,9 +95,9 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: # This is actually a pretty large object, but very little of the data seems useful to our app - RecordCtime: datetime = d_dict.get("RecordCtime", None) - RecordMtime: datetime = d_dict.get("RecordMtime", None) - ModifiedByDevice: str = d_dict.get("ModifiedByDevice", None) + RecordCtime: datetime | None = d_dict.get("RecordCtime", None) + RecordMtime: datetime | None = d_dict.get("RecordMtime", None) + ModifiedByDevice: str | None = d_dict.get("ModifiedByDevice", None) res = { "creationTime": _toUnixEpochMs(RecordCtime), @@ -109,8 +114,11 @@ def decodeBeaconNamingRecordCloudKitMetadata(cleanedBase64: str) -> dict: return None -def _convertToJavaDictWrapper(method: SyncSecondFactorMethod): - return_obj = { +def _convertToJavaDictWrapper(method: SyncSecondFactorMethod) -> dict[str, Any]: + # Deliberately heterogeneous: it carries the method object plus the ints and strings + # the Java side reads back out. Without the annotation the type is inferred from the + # first entry alone, and every later assignment looks like an error. + return_obj: dict[str, Any] = { "obj": method } @@ -175,17 +183,68 @@ def loginSync(email: str, password: str, anisetteServerUrl: str) -> dict: def exportToString(account: AppleAccount) -> str: - return json.dumps(account.export()) + """ + Replaces the old account.export() pattern. In FindMy 0.9.x, AppleAccount uses to_json/from_json. + The returned dict (AccountStateMapping) embeds the anisette provider state, so the + server URL no longer needs to be supplied at restore time. + """ + return json.dumps(account.to_json()) + + +# The only anisette provider that works on Android. `aniLocal` needs the unicorn CPU +# emulator to run Apple's ADI blob, and Chaquopy cannot build unicorn's native code - +# which is why app/stubs/unicorn exists at all. +SUPPORTED_ANISETTE_TYPE = "aniRemote" + + +def assertAnisetteIsSupported(serializedAccountData: str) -> str | None: + """ + Check a stored account uses a provider we can actually run, before anything tries + to use it. + + Without this the failure surfaces as a NotImplementedError raised from inside the + stub `unicorn` package, several layers down in anisette, at whatever unlucky moment + the provider is first exercised. Checking the serialized state up front turns that + into a clear message at the app boundary. + + :returns: None if supported, otherwise a human-readable reason. + """ + try: + data = json.loads(serializedAccountData) + anisette_type = (data.get("anisette") or {}).get("type") + + if anisette_type == SUPPORTED_ANISETTE_TYPE: + return None + if anisette_type is None: + return "This saved login has no Anisette configuration and cannot be restored." + return ( + f"This saved login uses an unsupported Anisette provider ({anisette_type}). " + "OpenTagViewer supports remote Anisette servers only on Android - local " + "Anisette needs a CPU emulator that cannot be built for this platform." + ) + except Exception: + print(f"Could not inspect anisette configuration: {traceback.format_exc()}") + return "This saved login could not be read." def getAccount( - serializedAccountData: str, anisetteServerUrl: str) -> AppleAccount: + serializedAccountData: str, + anisetteServerUrl: str | None = None) -> AppleAccount | None: + """ + Restore an AppleAccount via FindMy 0.9.x's `from_json`. The anisette provider is rebuilt + from the embedded state inside the JSON, so `anisetteServerUrl` is unused here. We accept + the parameter for now to keep the existing Java callsite compiling; it will be dropped + in Phase 2 when the Java bridge is updated. + """ try: + unsupported = assertAnisetteIsSupported(serializedAccountData) + if unsupported: + print(f"Refusing to restore account: {unsupported}") + return None + data = json.loads(serializedAccountData) - anisette = RemoteAnisetteProvider(anisetteServerUrl) - acc = AppleAccount(anisette) - acc.restore(data) + acc = AppleAccount.from_json(data) print(f"Login State: {acc.login_state}") @@ -196,46 +255,413 @@ def getAccount( return None +def convertPlistToJson( + plistXmlString: str, + alignmentPlistXmlString: str | None = None) -> str | None: + """ + One-shot conversion from the legacy plist XML representation (still stored in + OwnedBeacon.content) to the JSON form that FindMy 0.9.x expects. + + Used in two places (called from Java): + - During .zip import: convert once and store alongside the raw plist + - As a lazy backfill: when reading an OwnedBeacon row that predates the upgrade + + `alignmentPlistXmlString` is the accessory's KeyAlignmentRecord, if the export + contained one (format 0.0.2 and later). It supplies the rolling-key index macOS last + observed, so fetching can start there. Without it the accessory starts at index 0 from + its pairing date and the first fetch searches the tag's entire history - tens of + thousands of keys for an older tag. Optional, because exports predating 0.0.2 have no + such record and must keep working. + + Returns None on failure so Java can decide how to recover. + """ + try: + fp = BytesIO(plistXmlString.encode('utf-8')) + + # from_plist accepts bytes for the alignment record but not a file object, + # unlike its first parameter. + alignment_bytes = ( + alignmentPlistXmlString.encode('utf-8') if alignmentPlistXmlString else None + ) + + accessory = FindMyAccessory.from_plist(fp, alignment_bytes) + return json.dumps(accessory.to_json()) + except Exception: + print(f"convertPlistToJson failed: {traceback.format_exc()}") + return None + + +def _filterReportsByTimeRange(reports, startMs, endMs): + """ + Apple's network only ever returns ~7 days of history and 0.9.x removed the + user-facing time-range parameters from fetch_location_history. We filter + here so the Java side keeps the same time-window semantics it had before. + """ + out = [] + for r in reports: + ts_ms = _toUnixEpochMs(r.timestamp) + if ts_ms is None: + continue + if startMs is not None and ts_ms < startMs: + continue + if endMs is not None and ts_ms > endMs: + continue + out.append(r) + return out + + +# Apple accepts at most ~290 hashed keys per request, and FindMy.py walks the key index +# range one step at a time (15 minutes per step for an AirTag). Anything wider than this +# is enough round trips to be worth avoiding. +_ALIGNMENT_PROBE_THRESHOLD_INDICES = 2000 + +# Apple rejects requests carrying much more than ~290 hashed keys, so a ranged fetch is split +# into chunks below that with a little headroom. +_MAX_KEYS_PER_REQUEST = 255 + +# Ceiling on how many requests one ranged fetch may make. A single day is ~96 indices for an +# AirTag, so roughly one request; the cap only bites when alignment is unknown and the key +# range balloons. Without it, a history screen could quietly fire hundreds of requests at +# Apple - the account-flagging risk from issue #30, arriving through a different door. +_MAX_REQUESTS_PER_RANGE_FETCH = 8 + +# Attempts per request. Apple's endpoint times out often enough to see it by hand, and for a +# single day the range is one request - so one timeout meant a whole day of history came back +# empty. Two attempts, not more: this is a retry against a rate-sensitive endpoint. +_RANGE_FETCH_ATTEMPTS = 2 +_RANGE_FETCH_RETRY_DELAY_SECONDS = 1 + + +def _isAlignmentWide(accessory: FindMyAccessory, start, end) -> int: + """Width of the key-index range a history fetch would search, or 0 if unknown.""" + try: + return accessory.get_max_index(end) - accessory.get_min_index(start) + except Exception: + print(f"Could not determine key index width: {traceback.format_exc()}") + return 0 + + +def _fetchReportsForAccessory(account: AppleAccount, accessory: FindMyAccessory, start, end): + """ + Fetch reports for one accessory, avoiding a full-history key search when possible. + + Without a key alignment record an accessory starts at index 0 from its pairing date, + so a history fetch searches the tag's entire life - measured at ~50,000 indices for an + 18-month-old AirTag, which at ~290 keys per request is hundreds of round trips. That is + the account-flagging risk from issue #30. + + When the window is that wide we ask for the latest location *instead of* the history, + not before it. `fetch_location` walks backwards from now and stops at the first hit, and + alignment is updated as a side effect of any successful fetch. So one call both returns + something useful and collapses the window for every fetch that follows. + + Doing it as a probe *before* a history fetch was worse: for a tag with no recent reports + the probe traverses the whole range, finds nothing, narrows nothing, and then the history + fetch traverses it all over again - double the work, in the worst case rather than the + best. Replacing the call avoids that. + """ + width = _isAlignmentWide(accessory, start, end) + + if width > _ALIGNMENT_PROBE_THRESHOLD_INDICES: + print(f"Key search window is {width} indices wide; fetching latest location only, " + f"to establish alignment without searching the tag's whole history.") + latest = account.fetch_location(accessory) + + narrowed = _isAlignmentWide(accessory, start, end) + if narrowed < width: + print(f"Key search window narrowed from {width} to {narrowed} indices; " + f"subsequent fetches will search the narrow range.") + else: + # No report found anywhere in range. Nothing to align to, and a history fetch + # would search the same empty range again, so don't. + print("No reports found for this accessory; leaving alignment untouched and " + "skipping the history fetch.") + + return [latest] if latest is not None else [] + + return account.fetch_location_history(accessory) + + +def _chunk(items, size): + """Split a list into consecutive chunks of at most `size`.""" + return [items[i:i + size] for i in range(0, len(items), size)] + + +def _fetchReportsInRange(account: AppleAccount, accessory: FindMyAccessory, start, end): + """ + Fetch the reports an accessory produced inside a specific time window. + + `fetch_location_history(accessory)` cannot do this. For a rolling-key accessory it calls + `_fetch_accessory_reports(..., only_latest=True)`, which walks backwards from *now* and + returns as soon as the first batch of keys yields anything - roughly the last day, since + an AirTag steps its index every 15 minutes and Apple takes ~290 keys per request. It also + takes no date range at all: 0.9.x removed the range parameters that 0.7.6 had. + + So asking for last Tuesday returned today's reports, which the caller then filtered away + to nothing. The history screen showed data for today and empty days behind it, for every + tag, with no error anywhere. + + What the library does expose is the two halves needed to do it properly: + + * `accessory.keys_between(start, end)` yields `(index, key)` for exactly the window, + both primary and secondary, already de-duplicated + * `fetch_location_history(list_of_keys)` batches plain keys into a single request and + decrypts what comes back + + So we generate the window's keys ourselves, ask for those, and update the accessory's + alignment from each report - `keys_between` tells us which index a key belongs to, which + is the piece `_fetch_key_reports` cannot know and therefore does not do. + + Ordering note: alignment is established *before* generating keys, not after. An accessory + with no alignment spans a huge index range, so `keys_between` would yield tens of + thousands of keys for a single day. One `fetch_location` collapses that first. This is the + opposite order to `_fetchReportsForAccessory`, and deliberately so: there, a probe was + wasted work because the caller only wanted the latest report anyway; here the range is the + whole point, so the probe pays for itself. + """ + if not _alignBeforeRangedFetch(account, accessory, start, end): + return [] + + indexed_keys = _keysForRange(accessory, start, end) + if not indexed_keys: + return [] + + index_by_key = {key: index for index, key in indexed_keys} + chunks = _chunk(indexed_keys, _MAX_KEYS_PER_REQUEST) + + reports = [] + failed = 0 + for chunk in chunks: + try: + reports.extend(_fetchChunkAndAlign(account, accessory, chunk, index_by_key)) + except _ChunkFetchError: + failed += 1 + + if failed == len(chunks): + # Every request failed, so we know nothing about this range. Returning an empty list + # would be indistinguishable from "Apple has no reports here", and the history screen + # would show a confident, wrong "0 reports" for the day. Raising lets the caller skip + # the accessory instead of reporting an absence it cannot vouch for. + raise RuntimeError( + f"Every request in the ranged fetch failed ({failed} of {len(chunks)}); " + f"refusing to report an empty range") + + if failed: + print(f"WARNING: {failed} of {len(chunks)} requests failed; this range is incomplete") + + print(f"Ranged fetch searched {len(indexed_keys)} keys in {len(chunks)} request(s)") + return reports + + +def _alignBeforeRangedFetch(account: AppleAccount, accessory: FindMyAccessory, start, end) -> bool: + """ + Narrow the key index range before generating keys for it. + + An accessory with no alignment spans its whole life, so `keys_between` would yield tens of + thousands of keys for a single day. One `fetch_location` collapses that. + + @return whether the ranged fetch is worth doing at all + """ + width = _isAlignmentWide(accessory, start, end) + if width <= _ALIGNMENT_PROBE_THRESHOLD_INDICES: + return True + + print(f"Key search window is {width} indices wide; establishing alignment before " + f"fetching the requested range.") + account.fetch_location(accessory) + + narrowed = _isAlignmentWide(accessory, start, end) + if narrowed >= width: + # Nothing was found anywhere, so there is nothing to align to and the ranged sweep + # would search the same empty range again. + print("No reports found for this accessory; skipping the ranged fetch.") + return False + + print(f"Key search window narrowed from {width} to {narrowed} indices.") + return True + + +def _keysForRange(accessory: FindMyAccessory, start, end): + """The `(index, key)` pairs covering a time window, capped at what one fetch may search.""" + try: + indexed_keys = list(accessory.keys_between(start, end)) + except Exception: + print(f"Could not generate keys for the requested range: {traceback.format_exc()}") + return [] + + if not indexed_keys: + print("No keys fall inside the requested range; nothing to fetch.") + return [] + + max_keys = _MAX_KEYS_PER_REQUEST * _MAX_REQUESTS_PER_RANGE_FETCH + if len(indexed_keys) > max_keys: + # keys_between yields ascending by index, so the tail is the most recent part of the + # window - the part most likely to still hold reports Apple has not dropped. + print(f"Requested range needs {len(indexed_keys)} keys, more than the {max_keys} this " + f"fetch is allowed; searching only the most recent part of the range.") + indexed_keys = indexed_keys[-max_keys:] + + return indexed_keys + + +class _ChunkFetchError(Exception): + """One request of a ranged fetch failed, after its retries.""" + + +def _fetchChunkAndAlign(account: AppleAccount, accessory: FindMyAccessory, chunk, index_by_key): + """ + Fetch one request's worth of keys and feed what comes back into the accessory's alignment. + + Retried once, because Apple's endpoint times out often enough to hit by hand. A day of + history is a single request, so one `aiohttp` timeout meant the whole day came back empty + and the screen said "0 reports" - which reads as "your tag was not seen", not "the network + failed". + + The alignment update is the part `_fetch_key_reports` cannot do for us: it is handed plain + keys and has no idea which index each belongs to. We do, because `keys_between` said so. + + @raise _ChunkFetchError if every attempt failed + """ + keys = [key for _, key in chunk] + found = None + + for attempt in range(1, _RANGE_FETCH_ATTEMPTS + 1): + try: + found = account.fetch_location_history(keys) + break + except Exception: + print(f"Request {attempt} of {_RANGE_FETCH_ATTEMPTS} for a chunk of " + f"{len(keys)} keys failed: {traceback.format_exc()}") + if attempt < _RANGE_FETCH_ATTEMPTS: + time.sleep(_RANGE_FETCH_RETRY_DELAY_SECONDS) + else: + raise _ChunkFetchError(f"all {_RANGE_FETCH_ATTEMPTS} attempts failed for a chunk of " + f"{len(keys)} keys") + + reports = [] + for key, key_reports in (found or {}).items(): + for report in key_reports or []: + reports.append(report) + _updateAlignment(accessory, report, index_by_key.get(key)) + return reports + + +def _updateAlignment(accessory: FindMyAccessory, report, index): + if index is None: + return + try: + accessory.update_alignment(report.timestamp, index) + except Exception: + print(f"Could not update alignment: {traceback.format_exc()}") + + +def _serializeReports(reports): + """ + Map FindMy 0.9.x LocationReport objects to the dict shape Java's mapResults expects. + Note: `published_at` and `description` no longer exist on LocationReport in 0.9.x; we + fall back to `timestamp` and an empty string respectively. Java's BeaconLocationReport + can absorb that without changes. + """ + items = [] + for report in sorted(reports): + items.append({ + "publishedAt": _toUnixEpochMs(report.timestamp), + "description": getattr(report, "description", "") or "", + "timestamp": _toUnixEpochMs(report.timestamp), + "confidence": report.confidence, + "latitude": report.latitude, + "longitude": report.longitude, + "horizontalAccuracy": report.horizontal_accuracy, + "status": report.status + }) + return items + + +def _resultOrError(res: dict, failures: int, num_items: int) -> dict | None: + """ + Decide whether a short result is an answer or a failure. + + Java's `mapResults` only raises when this module returns None; a dict missing a beacon + reads as "that beacon has no reports". So swallowing every failure and returning a partial + dict told the history screen, with complete confidence, that a day it had failed to fetch + was a day the tag was not seen - no error state, and no Retry button, because as far as + Java was concerned the call succeeded. + + A partial result is still worth returning: the accessories that did answer have fresh + reports and, more importantly, updated alignment worth persisting. + """ + if num_items and failures == num_items: + print(f"Every accessory failed ({failures} of {num_items}); reporting an error rather " + f"than an empty result.") + return None + + if failures: + print(f"WARNING: {failures} of {num_items} accessories failed; result is incomplete") + + return res + + def getLastReports( account: AppleAccount, - idToPList, - hoursBack: int) -> dict: - # JAVA typing: see https://chaquo.com/chaquopy/doc/current/python.html - # especially this: https://chaquo.com/chaquopy/doc/current/python.html#classes + idToAccessoryData, + hoursBack: int) -> dict | None: + """ + Fetch the most recent reports for each beacon over the requested time window. + + `idToAccessoryData` is a List from Java where each element exposes + getBeaconId() / getAccessoryJson() (the persisted FindMyAccessory JSON). + + Each entry in the result dict carries: + - "reports": list of report dicts (same shape as before) + - "updatedAccessoryJson": JSON string of the accessory AFTER fetch — Java must + write this back to OwnedBeacon.accessory_json so the rolling key alignment + survives across calls (this is the issue #30 fix). + """ try: res = {} - num_items = idToPList.size() - print(f"num_items is {num_items}") + num_items = idToAccessoryData.size() + print(f"getLastReports: num_items={num_items}, hoursBack={hoursBack}") + + now_ms = int(datetime.now(tz=timezone.utc).timestamp() * 1000) + start_ms = now_ms - (hoursBack * 60 * 60 * 1000) + failures = 0 for i in range(0, num_items): - pair = idToPList.get(i) - beaconId = pair.first - plistContent = pair.second + req = idToAccessoryData.get(i) + beaconId = req.getBeaconId() + accessoryJson = req.getAccessoryJson() print(f"Fetching report for {beaconId} for the last {hoursBack} hours...") - fp = BytesIO(plistContent.encode('utf-8')) - airtag = FindMyAccessory.from_plist(fp) - - reports = account.fetch_last_reports(airtag, hoursBack) - print(f"Got {len(reports)} reports for {beaconId}") - - items = [] - for report in sorted(reports): - items.append({ - "publishedAt": _toUnixEpochMs(report.published_at), - "description": report.description, - "timestamp": _toUnixEpochMs(report.timestamp), - "confidence": report.confidence, - "latitude": report.latitude, - "longitude": report.longitude, - "horizontalAccuracy": report.horizontal_accuracy, - "status": report.status - }) - - res[beaconId] = items - return res + airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc) + now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) + + # Per-accessory isolation. One beacon failing used to abort the whole call, + # which meant no beacon's updated alignment was persisted - so every later + # fetch started from the same wide range again and never converged. + try: + reports = _fetchReportsForAccessory(account, airtag, start_dt, now_dt) or [] + except Exception: + failures += 1 + print(f"Fetch failed for {beaconId}, continuing with the rest: " + f"{traceback.format_exc()}") + continue + + print(f"Got {len(reports)} raw reports for {beaconId}") + + filtered = _filterReportsByTimeRange(reports, start_ms, now_ms) + print(f" -> {len(filtered)} reports after filtering to last {hoursBack}h") + + res[beaconId] = { + "reports": _serializeReports(filtered), + # Always written, even when there were no reports: the alignment may still + # have moved, and persisting it is what stops the next fetch re-searching. + "updatedAccessoryJson": json.dumps(airtag.to_json()), + } + + return _resultOrError(res, failures, num_items) except Exception: err = traceback.format_exc() @@ -245,53 +671,54 @@ def getLastReports( def getReports( account: AppleAccount, - idToPList, + idToAccessoryData, unixStartMs: int, - unixEndMs: int) -> dict: - # JAVA typing: see https://chaquo.com/chaquopy/doc/current/python.html - # especially this: https://chaquo.com/chaquopy/doc/current/python.html#classes + unixEndMs: int) -> dict | None: + """ + Time-range variant. Apple's network only retains ~7 days of history; ranges further + back than that will return empty (the local Room cache is already the canonical store + for older history via DailyHistoryFetchRecord). + + Same input/output shape as `getLastReports`. + """ try: res = {} - num_items = idToPList.size() - print(f"num_items is {num_items}") + num_items = idToAccessoryData.size() + print(f"getReports: num_items={num_items}, range=[{unixStartMs}, {unixEndMs}]") + failures = 0 for i in range(0, num_items): - pair = idToPList.get(i) - beaconId = pair.first - plistContent = pair.second + req = idToAccessoryData.get(i) + beaconId = req.getBeaconId() + accessoryJson = req.getAccessoryJson() print(f"Fetching report for {beaconId} in time range {unixStartMs}-{unixEndMs}...") - fp = BytesIO(plistContent.encode('utf-8')) - airtag = FindMyAccessory.from_plist(fp) - - start: datetime = datetime.fromtimestamp( - unixStartMs/1000, - tz=timezone.utc - ) - end: datetime = datetime.fromtimestamp( - unixEndMs/1000, - tz=timezone.utc - ) - reports = account.fetch_reports(airtag, start, end) - print(f"Got {len(reports)} reports for {beaconId} for time range {unixStartMs}-{unixEndMs}") - - items = [] - for report in sorted(reports): - items.append({ - "publishedAt": _toUnixEpochMs(report.published_at), - "description": report.description, - "timestamp": _toUnixEpochMs(report.timestamp), - "confidence": report.confidence, - "latitude": report.latitude, - "longitude": report.longitude, - "horizontalAccuracy": report.horizontal_accuracy, - "status": report.status - }) - - res[beaconId] = items - return res + airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc) + end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc) + + try: + reports = _fetchReportsInRange(account, airtag, start_dt, end_dt) or [] + except Exception: + failures += 1 + print(f"Fetch failed for {beaconId}, continuing with the rest: " + f"{traceback.format_exc()}") + continue + print(f"Got {len(reports)} raw reports for {beaconId}") + + filtered = _filterReportsByTimeRange(reports, unixStartMs, unixEndMs) + print(f" -> {len(filtered)} reports after filtering to requested range") + + updated_accessory_json = json.dumps(airtag.to_json()) + + res[beaconId] = { + "reports": _serializeReports(filtered), + "updatedAccessoryJson": updated_accessory_json, + } + + return _resultOrError(res, failures, num_items) except Exception: err = traceback.format_exc() diff --git a/app/src/main/res/layout/activity_history_view.xml b/app/src/main/res/layout/activity_history_view.xml index 4eb5b56f..721fe824 100644 --- a/app/src/main/res/layout/activity_history_view.xml +++ b/app/src/main/res/layout/activity_history_view.xml @@ -27,18 +27,11 @@ android:layout_height="match_parent"> - - - + android:layout_height="match_parent" + android:background="@color/md_theme_background" + tools:context=".HistoryViewActivity" /> diff --git a/app/src/main/res/layout/activity_information.xml b/app/src/main/res/layout/activity_information.xml index 98f0ad07..22a001e3 100644 --- a/app/src/main/res/layout/activity_information.xml +++ b/app/src/main/res/layout/activity_information.xml @@ -17,12 +17,14 @@ android:background="@color/md_theme_background" tools:context=".InformationActivity"> + + android:orientation="vertical"> - - + + + + + + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="40dp">