diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2dbf886bc..37ee71417 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,19 +2,9 @@ name: Android CI on: push: - branches: + branches: - master - paths-ignore: - - 'source/**' - - '**.md' - - '.**' - - 'fastlane/**' pull_request: - paths-ignore: - - 'source/**' - - '**.md' - - '.**' - - 'fastlane/**' workflow_dispatch: jobs: @@ -37,97 +27,166 @@ jobs: distribution: 'temurin' java-version: 21 + - name: Install Linux host build dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc-multilib g++-multilib + - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Install NDK - run: echo "y" | sdkmanager --install "ndk;${{ env.NDK_VERSION }}" + - name: Install Android SDK packages + run: yes | sdkmanager --install "platforms;android-36" "build-tools;36.0.0" "platform-tools" "ndk;${{ env.NDK_VERSION }}" - - name: Install Cargo with aarch64-linux-android + - name: Add Android tools to environment + shell: bash + run: | + BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -n 1) + echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> "$GITHUB_ENV" + echo "$ANDROID_HOME/build-tools/$BUILD_TOOL_VERSION" >> "$GITHUB_PATH" + NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" + echo "ANDROID_NDK_HOME=$NDK_HOME" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$NDK_HOME" >> "$GITHUB_ENV" + echo "CC_aarch64_linux_android=$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android23-clang" >> "$GITHUB_ENV" + echo "AR_aarch64_linux_android=$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" >> "$GITHUB_ENV" + echo "Android build tools: $BUILD_TOOL_VERSION" + echo "Android NDK: $NDK_HOME" + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-linux-android + components: clippy, rustfmt + + - name: Install Android Rust targets + run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - - name: Add Rust targe tarchitectures + - name: Install just run: | - rustup target add x86_64-linux-android - rustup target add armv7-linux-androideabi + if ! command -v just >/dev/null 2>&1; then + cargo install just --locked + fi - name: Retrieve version + shell: bash run: | - echo VERSION=$(git rev-parse --short HEAD) >> $GITHUB_ENV - - # Split due https://github.com/mozilla/rust-android-gradle/issues/38 - - name: Build with Gradle (debug) - run: ./gradlew -PappVerName=${{ env.VERSION }} assembleDebug - env: - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - - - name: Build with Gradle (release) - if: ${{ !github.event.pull_request }} - run: ./gradlew -PappVerName=${{ env.VERSION }} assembleRelease + VERSION=$(git rev-parse --short HEAD) + APP_VERSION=$(awk '/^version:/ {print $2}' app_flutter/pubspec.yaml) + FLUTTER_BASE_VERSION="${APP_VERSION%%+*}" + FLUTTER_BUILD_NUMBER="${APP_VERSION##*+}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "FLUTTER_BUILD_NAME=${FLUTTER_BASE_VERSION}_${VERSION}" >> "$GITHUB_ENV" + echo "FLUTTER_BUILD_NUMBER=$FLUTTER_BUILD_NUMBER" >> "$GITHUB_ENV" + + - name: Run rewrite validation + run: just verify + + - name: Build Android Rust bridge libraries + run: ./gradlew --no-daemon ':core-getter:buildDebugApi_proxyRust[arm64-v8a]' ':core-getter:buildDebugApi_proxyRust[armeabi-v7a]' ':core-getter:buildDebugApi_proxyRust[x86_64]' + + - name: Configure Flutter release signing + if: ${{ github.event_name != 'pull_request' }} + shell: bash env: - ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.ALIAS }} + run: | + set -euo pipefail + : "${SIGNING_KEY:?SIGNING_KEY secret is required}" + : "${KEY_STORE_PASSWORD:?KEY_STORE_PASSWORD secret is required}" + : "${KEY_PASSWORD:?KEY_PASSWORD secret is required}" + : "${KEY_ALIAS:?ALIAS secret is required}" + printf '%s' "$SIGNING_KEY" | base64 --decode > app_flutter/android/upload-keystore.jks + { + printf 'storePassword=%s\n' "$KEY_STORE_PASSWORD" + printf 'keyPassword=%s\n' "$KEY_PASSWORD" + printf 'keyAlias=%s\n' "$KEY_ALIAS" + printf 'storeFile=../upload-keystore.jks\n' + } > app_flutter/android/key.properties + + - name: Build Flutter APK artifacts + shell: bash + run: | + cd app_flutter + flutter build apk --debug --build-name "$FLUTTER_BUILD_NAME" --build-number "$FLUTTER_BUILD_NUMBER" + flutter build apk --release --build-name "$FLUTTER_BUILD_NAME" --build-number "$FLUTTER_BUILD_NUMBER" - - name: Setup build tool version variable + - name: Locate Flutter APK artifacts shell: bash run: | - BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) - echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV - echo Last build tool version is: $BUILD_TOOL_VERSION - - - name: Sign Android release - if: ${{ !github.event.pull_request }} - id: sign - uses: r0adkll/sign-android-release@v1.0.4 + DEBUG_APK="app_flutter/build/app/outputs/flutter-apk/app-debug.apk" + RELEASE_APK="app_flutter/build/app/outputs/flutter-apk/app-release.apk" + test -f "$DEBUG_APK" + test -f "$RELEASE_APK" + echo "DEBUG_APK=$DEBUG_APK" >> "$GITHUB_ENV" + echo "RELEASE_APK=$RELEASE_APK" >> "$GITHUB_ENV" + + - name: Verify Flutter APK identity and signature + shell: bash env: - BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} - with: - releaseDirectory: app/build/outputs/apk/release - signingKeyBase64: ${{ secrets.SIGNING_KEY }} - alias: ${{ secrets.ALIAS }} - keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} - keyPassword: ${{ secrets.KEY_PASSWORD }} + EXPECTED_SIGNING_CERT_SHA256: ${{ secrets.EXPECTED_SIGNING_CERT_SHA256 }} + run: | + set -euo pipefail + aapt dump badging "$DEBUG_APK" | tee /tmp/upgradeall-debug-badging.txt + aapt dump badging "$RELEASE_APK" | tee /tmp/upgradeall-release-badging.txt + grep -F "package: name='net.xzos.upgradeall.debug'" /tmp/upgradeall-debug-badging.txt + grep -F "versionCode='$FLUTTER_BUILD_NUMBER'" /tmp/upgradeall-debug-badging.txt + grep -F "versionName='$FLUTTER_BUILD_NAME'" /tmp/upgradeall-debug-badging.txt + grep -F "package: name='net.xzos.upgradeall'" /tmp/upgradeall-release-badging.txt + grep -F "versionCode='$FLUTTER_BUILD_NUMBER'" /tmp/upgradeall-release-badging.txt + grep -F "versionName='$FLUTTER_BUILD_NAME'" /tmp/upgradeall-release-badging.txt + apksigner verify --print-certs "$RELEASE_APK" | tee /tmp/upgradeall-release-certs.txt + if [[ "${{ github.event_name }}" != "pull_request" ]] && grep -q "CN=Android Debug" /tmp/upgradeall-release-certs.txt; then + echo "::error::Flutter release APK is debug-signed; CI release signing did not take effect" + exit 1 + fi + actual_sha256=$(awk -F': ' '/Signer #1 certificate SHA-256 digest/ { gsub(":", "", $2); print toupper($2); exit }' /tmp/upgradeall-release-certs.txt) + expected_sha256=$(printf '%s' "$EXPECTED_SIGNING_CERT_SHA256" | tr -d ':[:space:]' | tr '[:lower:]' '[:upper:]') + if [[ -n "$expected_sha256" && "$actual_sha256" != "$expected_sha256" ]]; then + echo "::error::Flutter release APK signer SHA-256 does not match EXPECTED_SIGNING_CERT_SHA256" + exit 1 + fi + if [[ "${{ github.event_name }}" != "pull_request" && -z "$expected_sha256" ]]; then + echo "::warning::EXPECTED_SIGNING_CERT_SHA256 is not set; release signer identity was not pinned" + fi - - name: Upload debug apk + - name: Upload Flutter debug apk uses: actions/upload-artifact@v6 - if: ${{ !github.event.pull_request }} + if: ${{ github.event_name != 'pull_request' }} with: - path: './app/build/outputs/apk/debug/*.apk' - name: build_debug_${{ env.VERSION }} + path: ${{ env.DEBUG_APK }} + name: build_flutter_debug_${{ env.VERSION }} - - name: Upload release apk + - name: Upload Flutter release apk uses: actions/upload-artifact@v6 - if: ${{ !github.event.pull_request }} + if: ${{ github.event_name != 'pull_request' }} with: - path: ${{ steps.sign.outputs.signedReleaseFile }} - name: build_release_${{ env.VERSION }} + path: ${{ env.RELEASE_APK }} + name: build_flutter_release_${{ env.VERSION }} - name: Get apk info - if: ${{ !github.event.pull_request }} + if: ${{ github.event_name != 'pull_request' }} id: apk-info uses: hkusu/apk-info-action@v1 with: - apk-path: ${{ steps.sign.outputs.signedReleaseFile }} + apk-path: ${{ env.RELEASE_APK }} # - name: Upload mappings with App Center CLI -# if: ${{ !github.event.pull_request }} +# if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} # uses: zhaobozhen/AppCenter-Github-Action@1.0.1 # with: -# command: appcenter crashes upload-mappings --mapping app/build/outputs/mapping/release/mapping.txt --version-name ${{ steps.apk-info.outputs.version-name }} --version-code ${{ steps.apk-info.outputs.version-code }} --app DUpdateSystem/UpgradeAll +# command: appcenter crashes upload-mappings --mapping app_flutter/build/app/outputs/mapping/release/mapping.txt --version-name ${{ steps.apk-info.outputs.version-name }} --version-code ${{ steps.apk-info.outputs.version-code }} --app DUpdateSystem/UpgradeAll # token: ${{secrets.APP_CENTER_TOKEN}} - - name: Find debug APK - if: ${{ !github.event.pull_request }} - run: | - if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then - OUTPUT="app/build/outputs/apk/debug/" - DEBUG_APK=$(find $OUTPUT -name "*.apk") - echo "DEBUG_APK=$DEBUG_APK" >> $GITHUB_ENV - fi - - name: Generate Commit Message - if: ${{ !github.event.pull_request }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} run: | COMMIT_MESSAGE=$(git log -1 --pretty=format:%s) AUTHOR_NAME=$(git log -1 --pretty=format:%an) @@ -139,14 +198,14 @@ jobs: \`\`\`$COMMIT_MESSAGE\`\`\` by \`$AUTHOR_NAME\` See commit detail [Here]($COMMIT_URL) - Snapshot apk is attached" + Flutter snapshot apk is attached" echo "TELEGRAM_MESSAGE<> $GITHUB_ENV echo "$TELEGRAM_MESSAGE" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV - name: Send commit to Telegram - if: ${{ !github.event.pull_request }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} uses: xz-dev/TelegramFileUploader@v1 env: BOT_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} @@ -156,7 +215,7 @@ jobs: to-who: ${{ secrets.TELEGRAM_TO }} message: ${{ env.TELEGRAM_MESSAGE }} files: | - /github/workspace/${{ steps.sign.outputs.signedReleaseFile }} + /github/workspace/${{ env.RELEASE_APK }} /github/workspace/${{ env.DEBUG_APK }} - name: Delete workflow runs diff --git a/.github/workflows/upgradeall-rewrite-validation.yml b/.github/workflows/upgradeall-rewrite-validation.yml new file mode 100644 index 000000000..6469b016c --- /dev/null +++ b/.github/workflows/upgradeall-rewrite-validation.yml @@ -0,0 +1,69 @@ +name: UpgradeAll Rewrite Validation + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +jobs: + rewrite-validation: + name: Rewrite validation + runs-on: ubuntu-latest + env: + NDK_VERSION: 29.0.14206865 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: true + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Install Linux host build dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc-multilib g++-multilib + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + run: sdkmanager --install "platforms;android-36" "build-tools;36.0.0" "platform-tools" "ndk;${{ env.NDK_VERSION }}" + + - name: Add Android NDK to environment + run: | + NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" + echo "ANDROID_NDK_HOME=$NDK_HOME" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$NDK_HOME" >> "$GITHUB_ENV" + echo "CC_aarch64_linux_android=$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android23-clang" >> "$GITHUB_ENV" + echo "AR_aarch64_linux_android=$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" >> "$GITHUB_ENV" + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Install Android Rust targets + run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + + - name: Install just + run: | + if ! command -v just >/dev/null 2>&1; then + cargo install just --locked + fi + + - name: Run rewrite validation + run: just verify diff --git a/.gitignore b/.gitignore index 0adc12328..0fb45b922 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ /android-studio/sdk out/ /tmp +/.pi/ /intellij workspace.xml *.versionsBackup diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..85946e51b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md — UpgradeAll rewrite coding agent bootstrap + +This repository is being rewritten toward a Flutter APP + Rust getter core + Lua package repository architecture. + +Before coding, every agent MUST read: + +1. `docs/README.md` +2. `docs/architecture/upgradeall-getter-rewrite-wiki.md` +3. `docs/architecture/adr/0001-app-centric-lua-package-repository-model.md` +4. `docs/architecture/adr/0002-getter-flutter-platform-boundary.md` +5. `docs/architecture/adr/0003-legacy-room-migration.md` +6. `docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md` +7. `docs/architecture/adr/0005-lua-package-api.md` +8. `docs/architecture/adr/0006-package-centric-cli-command-contract.md` +9. `docs/architecture/adr/0007-flutter-getter-bridge-contract.md` +10. `docs/app/flutter-ui-feature-parity-and-testing.md` + +## Core architecture rules + +- Rust getter owns all product/domain logic. +- Rust getter lives in the `core-getter/src/main/rust/getter` git submodule (`https://github.com/DUpdateSystem/getter`) so it remains independently reusable; implement getter CLI/core changes inside that submodule and update the superproject gitlink, do not vendor getter source into the UpgradeAll superproject. +- Flutter owns UI and platform adapter only. +- Do not reintroduce the old hub-app model. +- Use readable package ids such as `android/org.fdroid.fdroid`, not UUID primary ids. +- Lua package files return JSON-like tables; Rust validates/deserializes them. +- Backend state uses SQLite main DB plus separate cache DB. +- Package Lua source files live in repository folders. +- `local` is user-authored override repo. +- `local_autogen` is generated fallback repo. +- Do not add runtime UI customization/plugin framework unless a later ADR changes this. + +## Testing rules + +Use mixed BDD and TDD. + +TDD is for function/domain behavior: + +- Rust functions. +- repository resolution. +- Lua validation. +- migration mapping. +- cache invalidation. +- version comparison. + +BDD is for UI/integration behavior: + +- Flutter flows. +- migration UX. +- installed autogen confirmation. +- yellow network warning tag. +- update/download task flow. + +BDD scenarios are self-explaining documentation tests. Do not over-test with BDD; keep scenarios meaningful and user-visible. + +## Implementation discipline + +- Make small, reviewable changes. +- Update docs/ADR when behavior or architecture changes. +- Do not edit generated files manually. +- Do not silently drop migration fields; document dropped fields. +- Do not put Android-specific APIs into getter core. +- Do not put provider/update/version/storage logic into Flutter UI. +- If uncertain, add a small ADR or update the architecture wiki before coding. + +## Suggested first implementation order + +1. Create Rust workspace skeleton for getter. +2. Define package id, repository, and Lua validation structs. +3. Implement repository layout loader. +4. Add mlua evaluation returning JSON-like tables. +5. Implement Rust schema validation. +6. Implement main DB/cache DB skeleton. +7. Implement legacy migration mapping tests. +8. Build minimal Flutter shell only after getter core can be exercised by CLI. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..40f67c8ea --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# Domain Context + +## Glossary + +### Lua update runtime + +The getter-owned runtime that evaluates a package's complete Lua lifecycle from capability checks through update action resolution. It is the center of UpgradeAll's update behavior: package Lua supplies a fully materialized lifecycle contract, getter supplies host APIs and validation, and the runtime produces getter-owned update/download/install DTOs for the app to render or execute through platform adapters. + +### Complete lifecycle contract + +The package shape consumed by getter's Lua update runtime after Lua templates/base classes have filled defaults. For getter runtime purposes, lifecycle functions are not missing or optional: every package has the full supported lifecycle surface, even if some functions come from a template default rather than package-specific Lua code. + +### Lifecycle entrypoint + +A scenario-specific Lua function that getter invokes after loading a complete package into memory. Getter chooses the entrypoint for the scenario, such as matching, update checking, action resolution, or post-update handling. Getter does not hard-code the internal Lua call graph; once the entrypoint is invoked, Lua/template code may call other lifecycle functions or helpers as needed within the validated contract. + +### Installed version entrypoint + +A Lua lifecycle entrypoint/template method that resolves the currently installed/local version for a package. This function exists as part of the complete lifecycle contract. For normal non-live update checks, the effective local baseline is `pin_version` when the user has set one; otherwise getter uses this Lua entrypoint as the baseline source. The DTO should keep the observed/local version, local-version status, and the effective comparison baseline separate so UI/CLI can display both when `pin_version` overrides local version. The installed version entrypoint returns a structured value such as `{ status = "present", version = "1.2.3", extra = { version_code = 123 } }` or `{ status = "not_installed" }`; platform/API failures use Lua errors such as `error("reason")`, not not-installed values. Without a `pin_version` override, getter must have a `present` local version to compare; if the entrypoint reports `not_installed`, there is no local baseline to display or compare, and if it raises an error getter reports the Lua/platform version-source error. With a `pin_version` override, getter may still call the installed version entrypoint for display; if that call fails, getter reports a local-version diagnostic but continues comparison against `pin_version`; if it reports `not_installed`, UI omits the local version row and still shows/uses `pin_version`. For Android apps with a standard version source, the default Lua template can simply call the getter/platform host API that reads platform-specific package facts such as version name/code, return `not_installed` when the app is absent, and raise a Lua error if the platform call itself fails; special packages can override or inherit a different Lua implementation. For live packages, getter uses the `present` result as the local baseline; if it returns `not_installed`, that means no local baseline is semantically available, and getter falls back to the last successfully installed/accepted live version recorded in getter state. If the live package's installed version entrypoint raises an error, getter must not fall back unless a `pin_version` override supplies the effective baseline for that check. + +### Getter operation + +A product-level getter API such as update check, task submission, task cancellation, or installed-autogen preview/apply. Flutter and stable CLI commands should call getter operations rather than individual Lua lifecycle functions. Direct lifecycle entrypoint calls are diagnostic/test tooling, not the product bridge contract. + +### Version behavior model + +The rewrite does not preserve the old Kotlin version-number stack wholesale. Lua packages/templates own local-version acquisition through lifecycle inheritance/override. Getter supplies host/platform APIs and small helper tools for common version extraction/comparison tasks, such as regex-based extraction and platform facts like Android version name/code, but Lua/template code decides when and how to use them. New rewrite domain language uses `pin_version`, not `ignore_version`: `pin_version` is a persisted user-selected local version override stored in `main.db` tracked package state, not a transient update-check parameter and not cache data. In the first implementation, `pin_version` is a scalar UTF-8 string so CLI usage stays simple, e.g. `getter version pin ` rather than hand-written JSON; pin/unpin commands mutate durable getter state. When set, getter compares upstream candidates against `pin_version` as the effective local version instead of the platform/Lua-installed version result. Other version comparison behavior remains the normal package/version comparison behavior. UI/CLI display should still show both observed local version and `pin_version` when an observed local version exists: Flutter shows local version above and bold pin version below, with latest version on the right; CLI compact display uses `version: (~~)` where the tilde-marked value is the pin override. If local version acquisition errors while `pin_version` is set, the check may still proceed using `pin_version`, but the error must be visible as a diagnostic. If the package is explicitly not installed/no-local, UI omits the local version row instead of showing an error. Legacy Room `ignore_version_number` / transitional `ignored_version` inputs map into rewrite `pin_version`, and legacy migration reports must emit an informational rename note so reviewers/users can see that the setting was preserved under the new name. Legacy invalid/include regex fields are migration inputs or Lua-template helper parameters, not global getter-owned version behavior. If structured pin metadata or extra fields are needed later, CLI must expose ergonomic flags or a separate advanced command rather than requiring users to type raw JSON. + +### Lua template class + +A Lua-side reusable template/base abstraction that fills default lifecycle function implementations before getter validates and runs a package. Template defaults are authoring convenience, not getter runtime optionality; getter receives the completed lifecycle contract. + +### Lua dependency closure + +The set of Lua package files, template classes, helper modules, parent package imports, and runtime/API versions that shape a completed package lifecycle contract and its package metadata. Package metadata cache entries are valid only for the same Lua dependency closure and operation context. + +### Side-effect executor + +A runtime boundary that performs effects requested by resolved update actions, such as network fetches, downloads, installer handoffs, Android system notifications, or platform callbacks. The Lua update runtime may be implemented before every side-effect executor is real, as long as executor boundaries and events are shaped like the future product behavior. The first mock download executor simulates progress and task state only; it does not write real files, validate real artifacts, or perform artifact handoff. The first mock install executor also simulates state only and does not trigger a real Android installer/handoff, but it must exercise a fake user-waiting handoff state using `status = running` and `phase = { category = "waiting_user", reason = "install_handoff" }`. Fake waiting-user install handoff does not auto-complete; it is completed through a generic task-level `user-result` method so future real installer, permission, SAF, confirmation, or other user-mediated callbacks and tests share the same task continuation boundary instead of special-casing install. `user-result` uses user-facing semantic outcomes `accepted` and `rejected` rather than raw task terminal statuses; getter maps those outcomes to the next task state or continuation behavior. `user-result` does not include a `canceled` outcome: canceling the whole task remains the separate `task cancel` method. `accepted` represents the successful/accepted outcome of the user-mediated step at the granularity the platform adapter can observe; Android does not provide a stable separate boundary for "user just agreed but install is not complete", so the rewrite does not introduce a separate `completed` user-result outcome. For fake install handoff, `accepted` continues the mock install and completes the task as `completed`; `rejected` maps to `failed`, not `canceled`, so an accidental rejection remains retryable on the same task. `rejected` may include an optional reason; if omitted, getter supplies a default current diagnostic such as `user.rejected`. `user-result` is valid only while the task is in a user-waiting phase such as `{ category = "waiting_user", reason = "install_handoff" }`; calling it in any other phase/status is an API error and must not mutate task state. For Android installer callbacks, the installer UI's "cancel" result is treated as this user-mediated `rejected` outcome rather than task cancellation. Platform-specific installation is a side-effect executor/handoff, not Flutter-owned product logic. + +### Runtime notification callback + +A getter-owned notification boundary used by native/Flutter UI to learn that runtime state changed and what it changed to. It is a callback/notification mechanism, not the source of truth and not a persisted event log. Task state is current getter-runtime process state only: it is never persisted to `main.db` or `cache.db`, and UpgradeAll does not support cross-process, app-restart, device-sleep, or downloader-style resume/recovery semantics because UpgradeAll is not a general-purpose downloader. After Flutter/Android/getter runtime process restart, the in-memory task registry is empty unless new tasks have been created in that process; UI must not present old tasks as recoverable or show a downloader-style interrupted-task recovery prompt. If the user still wants to update, they start again from the package/update action. Any residual temporary-file cleanup belongs to the specific executor implementation and is not task recovery. Getter is a monolithic package-manager runtime object held by the product app/native bridge process, not a task daemon. In the native bridge product path, getter runtime is a process-lifetime singleton: native bridge initialization creates or retrieves the runtime, Flutter route/page rebuilds do not recreate task state, and app process death drops runtime/task state. First implementation supports a single main app engine callback binding and exposes runtime notifications to Flutter as a push stream, e.g. Android/Kotlin bridge `EventChannel` or equivalent. Product bridge is not polling-only: `RuntimeNotification.task_changed` snapshots are pushed to Flutter, while `task get`/`task list` remain authoritative query operations when UI/CLI needs current state. The native/Rust bridge product path supports one main Flutter subscription; if multiple Flutter pages/components need the stream, Dart owns broadcast/state-management fanout inside the app rather than requiring Rust/native multi-subscriber semantics. The stream is best-effort push: it should push notifications whenever it can, but it is not a reliable message queue and does not guarantee delivery of every intermediate progress snapshot. The bridge must avoid unbounded backpressure queues; it may coalesce/drop intermediate progress notifications, while authoritative current state remains available through `task list`/`task get`. The stream does not replay notifications missed while Flutter is unsubscribed or disconnected; reconnecting Flutter should resubscribe and immediately query current task state with `task list`/`task get`, then process newly pushed notifications. CLI/debug tooling may use query/polling/scripted commands instead of product stream semantics. Multi-engine or multi-isolate sharing semantics require a later ADR if needed. Product task submission uses a getter-issued opaque `action_id`. Update/check operations may send Flutter rich display DTOs for package/version/action presentation, but Flutter acts by returning only the `action_id`; it must not construct tasks by assembling or echoing raw URLs, checksums, installer types, package IDs, versions, or full action payloads itself. The getter runtime's internal action registry may and should hold a sealed action plan with full execution details such as package id, target version/live revision, artifact descriptors, checksum/signature expectations, installer/executor plan, and the bound Lua/package execution context; those details are internal getter execution data, not product bridge input. The sealed plan must be bound to the Lua/package context that produced the action and must not re-read current Lua files during submit or retry. The runtime should load, validate, and materialize the package/template/helper context into a package-version Lua object in memory before issuing the action, then execute that bound in-memory object/plan rather than behaving like a shell that reads and executes one line at a time. If task execution later needs Lua hooks or helper functions, it calls the bound package-version Lua object; the entire Lua call chain used by that object is already loaded/materialized in memory and is not resolved again from the filesystem. The package-version Lua object lives with the action/task that needs it: the action registry holds it until the action is consumed or expires; successful submit consumes the action and transfers the sealed plan/object to the task; failed tasks keep enough of the object to support retry; completed/canceled tasks may release it when the task is cleared from the current runtime registry; expired unsubmitted actions release it when the action registry cleans them up. `action_id` is scoped to the current getter runtime process and is not a persisted cross-process handle. `action_id` is single-use: once task submission successfully creates a task, getter consumes/removes that action from the runtime action registry. A consumed action is permanently gone within that runtime: it is not restored if the created task later fails, and it must not be reused because reuse would blur action lifecycle and task lifecycle. Reusing the same consumed action, such as from a UI double-submit, returns `action.not_found` instead of creating another task. If task submission references an expired or unknown `action_id`, getter returns `action.not_found` and must not automatically re-run update check or attempt to match a fresh package/version candidate, because that could change the candidate the user saw. Flutter should prompt the user to refresh package/update state and submit a new current-runtime action. CLI/debug tooling may use fixtures/scripts or full request JSON for tests, but the product bridge must stay anchored to getter-owned update/package operations. Runtime task scheduling does not impose a global serial queue or task-registry-level cross-task lock: concurrent downloads and concurrent installs are allowed, and tasks behave like independent branches in a task tree/forest. This tree/forest wording is only a mental model for independence, not an exposed parent/child task API. The first runtime does not need `parent_task_id` or visible download/install subtasks unless a later batch-update ADR introduces them. Tasks do not know about, wait for, or coordinate with sibling tasks through the runtime scheduler. However, package installation/state mutation for the same package is a package-scoped resource and must be protected by a package-level lock inside the relevant executor/operation. Installing the same package, whether for the same version or different versions, must not run its package mutation critical section concurrently. This package lock must not be implemented as task creation rejection, task deduplication, task merge, or a global task lock; if two tasks for the same package are created due to user action, race, or bug, the tasks may both exist. The package lock is non-waiting: when a task reaches the package mutation/install critical section and the same package is already locked by another task, this is treated as incorrect usage and the later task fails immediately with a user-visible diagnostic instead of waiting for the lock or entering a resource-waiting phase. The diagnostic code is `package.locked`, and task phase reason can use `package_locked` to make clear that the failing resource is the package mutation boundary, not the task scheduler. Download execution is task-local and does not use the package-level lock; even if two tasks download the same artifact, they are independent task-internal effects until a later package mutation/install boundary is reached. CLI task commands do not promise state across separate CLI invocations and must not create a task DB or daemon just to make pause/resume/user-result work across commands; tests should exercise the Rust runtime library directly or through a single-process scripted/debug command when CLI coverage is useful. The top-level payload is a generic `RuntimeNotification` with a `kind` discriminator; the first product kind is `task_changed`, carrying a lightweight but sufficiently complete current task snapshot. Callback payloads must include enough task snapshot data so Flutter can update UI without querying getter after every notification; this avoids unnecessary backend pressure. The current task snapshot includes task-state fields such as `task_id`, `package_id`, `status`, structured `phase`, `progress`, current control `capabilities`, optional `current_diagnostic`, and an `updated_at`/snapshot timestamp for UI ordering or stale-update handling. The snapshot is task state only, not package metadata or duplicated display metadata; external UI/callers are expected to know or query package metadata through the proper package/update APIs. Task progress supports at least `percent` and `bit` units; when bit-level current/total values are available, callbacks should prefer `bit` because it carries more information and percent can be derived from it. Task control supports cancel, retry, pause, and resume from the first runtime implementation. These controls are methods on an existing task object, not factories for new tasks. `task cancel` is valid for active `queued`, `running`, and `paused` tasks; a paused task can be canceled directly without first resuming. It is not valid for `failed`, `completed`, or already `canceled` tasks. Failed tasks should use retry or remove, while completed/canceled tasks are terminal. Task status values are `queued`, `running`, `paused`, `failed`, `completed`, and `canceled`; the rewrite uses `completed` and does not keep a `succeeded` alias. Waiting for user action is not a status: the task remains `running`, and the phase is structured as `{ category, reason? }`, e.g. `{ category: "waiting_user", reason: "install_handoff" }`. The first phase schema intentionally avoids extra fields such as detail/executor/localized message; progress, diagnostics, and UI text are separate concerns. In particular, `retry` retries the same task identified by the same `task_id`; it must not automatically create a new task. Only `completed` and `canceled` are truly terminal task states. Failed tasks are not terminal: if the current task state permits retry, retry reuses the same `task_id` and transitions the existing task back into a runnable state. Retry is a task method over the sealed, already-consumed action plan, not action reuse and not an implicit update refresh. It does not revalidate against the action registry, re-run update check, or match a fresh candidate; if the sealed artifact/package plan has become invalid, the retry fails naturally in the relevant execution phase such as download, validation, package lock, or install. It should resume from the failed phase when the runtime has enough task-local state: download failures retry download, fake install `rejected` failures retry the install handoff, and `package.locked` failures retry entering the package mutation boundary. If the runtime lacks enough intermediate state for a precise phase retry, it may restart from the task-internal action plan beginning, but still as the same task and without creating/restoring an `action_id`. If retry itself fails, getter returns an error and the caller may invoke retry again on the same task when the task state still permits retry. Completed, canceled, and failed tasks remain queryable task objects in the current runtime process indefinitely until an explicit manual cleanup operation removes them; there is no automatic TTL/capacity retention cleanup and no persistent storage. CLI should expose both `task remove ` for removing one in-memory task and `task clean` for explicit bulk cleanup of non-active in-memory tasks. `task remove ` is an explicit single-task operation and may remove `failed`, `completed`, or `canceled` tasks; removing a failed task also discards its retry capability. By default, `task clean` removes `completed` and `canceled` tasks only; it does not remove `failed` tasks because failed tasks may still be retryable. Removing failed tasks requires an explicit option such as `task clean --failed` or `task clean --all-inactive`, where all inactive means `completed`, `canceled`, and `failed`. Removal/cleanup must not remove active tasks such as queued/running/paused tasks; callers must cancel them first. After removal or cleanup, `task get` returns `task.not_found` for the removed task, and associated in-memory sealed action plan/Lua object may be released. The retry method/interface still exists, but retrying a completed or canceled task must immediately return an error and must not resurrect the task or create a replacement task. If the user wants a fresh task object after completion/cancellation, they must use the original task creation/submission path again. Pause/resume are task-level APIs, not download-executor-only public APIs, but whether they are currently allowed is phase/executor-specific and process-specific. Some phases, including `waiting_user`, are state snapshots rather than pausable processes, so they must expose `pause = false` and `resume = false` even while the task status remains `running`. `task resume` is valid only for a `paused` task whose current executor/phase supports resume; calling resume for `running`, `waiting_user`, `queued`, `failed`, `completed`, or `canceled` tasks returns an unsupported-control error and does not mutate state. Failed tasks use retry rather than resume. The first implementation must support pause/resume for download-phase tasks; other phases can expose false capabilities. Task snapshots include current capability flags for these controls so Flutter does not infer executor-specific behavior from status/phase; a capability may be false for the current task/phase, but the API/control model exists. Calling an unsupported control for the current state/phase, such as pausing a `waiting_user` phase or resuming a non-paused task, returns an explicit error such as `task.pause_not_supported`, `task.resume_not_supported`, `task.retry_not_supported`, or `task.cancel_not_supported` and must not mutate task state; unsupported controls are never silent no-ops. Task snapshots include at most the current diagnostic summary needed for UI display, not a diagnostic history or event log; detailed diagnostics/logs use separate query operations. Every field included in a task snapshot, including capabilities and current diagnostic summary, must also be obtainable by active query operations or a combination of query operations within the current runtime process; the callback exists to reduce getter/UI polling pressure, not to become the only way to learn state. If UI/CLI explicitly wants authoritative task state, it calls separate getter operations that query/recompute current internal task state, including at least single-task lookup and task listing/summary for active tasks or package-scoped tasks. This is not the same as an Android system notification. In the first Phase D runtime slice, downloads and installers may be mock side-effect executors, but runtime callbacks must be shaped like product notifications so Flutter can refresh task/progress UI without owning the task state machine. + +### Provider host API + +A getter-owned API exposed to package Lua for requesting provider/source data. Lua packages should call provider host APIs rather than performing arbitrary direct HTTP by default. The provider executor behind the host API can be fake during early runtime development and live later, but caching, diagnostics, permissions, and output validation belong to the Lua update runtime boundary. + +### Package metadata cache + +The cache of software metadata produced by running package Lua/provider logic, analogous in spirit to Gentoo eix's binary cache over package metadata. It stores reusable metadata such as package identity, descriptions, homepage/source information, available versions/candidates, changelog/release notes when provided by sources, artifact descriptors, licenses/tags, and source/provider diagnostics. It is persisted in `cache.db` from the first runtime implementation. It is keyed by the getter-tracked Lua dependency closure plus runtime context that can affect metadata. Freshness is determined by provider/source freshness tokens such as ETag, Last-Modified, source cursor, index revision, or response digest when available; TTL is only a fallback revalidation hint. If the Lua dependency closure changes, the runtime may reuse unchanged provider/source cache as input, but it must rerun Lua normalization and create/update the current PackageMetadata entry for the new closure digest. A forced refresh bypasses existing cached reads and updates/replaces the relevant cache entries on success so `cache.db` reflects the newly observed source facts. If forced refresh fails, the runtime must not delete still-usable old cache entries, but it must report refresh failure/staleness explicitly and must not present old cache as a successful fresh synchronization. Cache consistency is a design invariant for later Phase D decisions. Cache is not an audit log; product semantics only require the current effective cache entry, and old entries may be garbage-collected. Artifact descriptors inside PackageMetadata are package-management contracts, not mutable cache truth: a versioned artifact's URL/locator, size, checksum, signature, and content identity describe the expected file. If upstream changes the file or a downloaded file does not match declared metadata/hash, getter treats that as an invalid artifact/download failure rather than silently accepting or refreshing the artifact identity. Live/floating behavior is a package/Lua-level flag, analogous to Gentoo `9999` live ebuilds, not an artifact-level flag. Getter/UI must surface live versions before download/task submission because artifact-stage detection is too late for user awareness. Live version checks are opt-in and require a separate live flag such as `--live`; live packages do not participate in the ordinary versioned update check by default. The live update rule is simple: run the live Lua path to obtain the current live version string, compare it with the local baseline, and report an available update when they differ. The local baseline comes from the package's installed version entrypoint when it returns `{ status = "present", ... }`; if it returns `{ status = "not_installed" }`, live checks fall back to getter's last successfully installed/accepted live version. The entrypoint still exists in the complete lifecycle contract. If the entrypoint raises a Lua error because a platform/API call failed, getter must report that error and must not fall back. A live version is an arbitrary valid UTF-8 string; getter does not parse, order, or validate it as a semantic version. A live package may delegate arbitrary/latest download resolution to Lua, but those results are not cacheable as stable artifact metadata because upstream may change at any time. The cache is not the authoritative user state and should not cache final user-state-dependent decisions such as pin_version override state, task state, or installer results. diff --git a/app_flutter/.gitignore b/app_flutter/.gitignore new file mode 100644 index 000000000..29a3a5017 --- /dev/null +++ b/app_flutter/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app_flutter/.metadata b/app_flutter/.metadata new file mode 100644 index 000000000..c1f9a6bfc --- /dev/null +++ b/app_flutter/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + base_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + - platform: android + create_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + base_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + - platform: linux + create_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + base_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app_flutter/README.md b/app_flutter/README.md new file mode 100644 index 000000000..3460eceb1 --- /dev/null +++ b/app_flutter/README.md @@ -0,0 +1,48 @@ +# UpgradeAll Flutter app + +This is the new Flutter shell and product APK entry for the UpgradeAll rewrite. It must remain a UI and platform adapter around the Rust getter core; product logic, repository resolution, storage, and migration behavior belong in getter. The legacy Android `:app` UI is kept only as reference code during migration. + +## Toolchain baseline + +- Flutter stable `>=3.44.4` +- Dart SDK `>=3.12.2 <4.0.0` +- Gradle `9.3.1`, Android Gradle Plugin `9.0.1`, Kotlin Gradle Plugin `2.3.20` +- Android product APK `minSdkVersion` follows `flutter.minSdkVersion` from the active stable Flutter SDK (Flutter 3.44 currently uses Android API 24). + +Do not validate the rewrite with an older local Flutter SDK; older Flutter tester/Impeller builds can crash in widget tests and do not match CI. Do not pin the Flutter product APK to an Android API level below the active stable Flutter SDK baseline just to preserve old local compatibility. + +## Current slice + +- Android release application identity: `net.xzos.upgradeall` +- Android debug application identity: `net.xzos.upgradeall.debug` +- Stable route/action/state keys for widget and future integration/dev tests +- Placeholder routes for apps, repositories, downloads, logs, settings, and legacy migration +- `FakeGetterAdapter` for deterministic widget tests +- `CliGetterAdapter` as a development/integration bridge against the real `getter-cli` JSON envelope +- A slim Android `:getter_bridge` library inside `app_flutter/android/getter_bridge` packages the Rust `api_proxy` native library and the no-UI installed-inventory provider classes into the Flutter product APK without depending on the legacy native `:app` UI or old `GetterPort` RPC wrapper surface. +- `MainActivity` exposes a no-UI `net.xzos.upgradeall/getter_bridge` MethodChannel for native bridge plumbing. The legacy migration and installed-autogen methods derive the app-private getter data directory on Android, call Rust JNI entrypoints, and return getter-style JSON envelopes consumed by `MethodChannelGetterAdapter`. +- Product manifest permissions include `QUERY_ALL_PACKAGES` per ADR-0009 so the Rust-active Android platform adapter can provide complete installed package inventory facts to getter. + +`CliGetterAdapter` is not the final Android production bridge. It exists to keep the getter-owned DTO and error contract executable for dev tests. `MethodChannelGetterAdapter` is the current production bridge slice for direct legacy Room import/report-list and installed-autogen preview/apply: Flutter renders getter-owned DTOs and passes user choices/paths back to getter, but Room mapping, PackageManager scanning, package-id decisions, and `local_autogen` writes remain in Rust/native getter code. + +## Verification + +```bash +flutter analyze +flutter test +GETTER_CLI_BIN=/path/to/getter-cli flutter test dev_test/cli_getter_adapter_test.dart +``` + +Device/emulator bridge validation is available when an Android device is attached: + +```bash +flutter test integration_test/native_bridge_test.dart -d emulator-5554 +# or, from the repository root: +just test-flutter-device-bridge emulator-5554 +``` + +The device bridge test exercises the production MethodChannel/JNI path for copied legacy Room import/report-list and installed-autogen preview/apply. If using the local `Pixel_9a` AVD, start it with enough memory (for example `-memory 4096`) so the Flutter debug VM is not killed by Android low-memory pressure. + +From the repository root, `just verify` also runs the Flutter analyzer, widget tests, getter CLI integration/dev test, Android debug build, and an APK inspection that verifies the Flutter APK contains `libapi_proxy.so`, `NativeLib`, and `InstalledInventoryProvider`. `just verify` intentionally does not require an attached device; use `just test-flutter-device-bridge` for the emulator-only path. + +Android CI/release artifacts are built from this Flutter project with `flutter build apk`; the root Gradle `:app` module is no longer the rewrite product APK path. diff --git a/app_flutter/analysis_options.yaml b/app_flutter/analysis_options.yaml new file mode 100644 index 000000000..0d2902135 --- /dev/null +++ b/app_flutter/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/app_flutter/android/.gitignore b/app_flutter/android/.gitignore new file mode 100644 index 000000000..7760dbbdf --- /dev/null +++ b/app_flutter/android/.gitignore @@ -0,0 +1,10 @@ +/.gradle +/captures/ +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/app_flutter/android/app/build.gradle b/app_flutter/android/app/build.gradle new file mode 100644 index 000000000..4fce18115 --- /dev/null +++ b/app_flutter/android/app/build.gradle @@ -0,0 +1,92 @@ +plugins { + id "com.android.application" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '105' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '0.20.0-alpha.4' +} + +// Release signing is configured by CI/local key.properties. Without it, +// release builds remain debug-signed so local Flutter builds keep working. +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace "net.xzos.upgradeall" + compileSdkVersion 36 + ndkVersion "29.0.14206865" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "net.xzos.upgradeall" + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + signingConfigs { + release { + if (keystorePropertiesFile.exists()) { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + } + + buildTypes { + debug { + applicationIdSuffix ".debug" + } + release { + if (keystorePropertiesFile.exists()) { + signingConfig signingConfigs.release + } else { + // Keep local release builds runnable when no private keystore is present. + signingConfig signingConfigs.debug + } + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation project(':getter_bridge') + testImplementation "junit:junit:4.13.2" + testImplementation "org.json:json:20250517" +} diff --git a/app_flutter/android/app/src/debug/AndroidManifest.xml b/app_flutter/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..457b50893 --- /dev/null +++ b/app_flutter/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app_flutter/android/app/src/main/AndroidManifest.xml b/app_flutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..561ef8201 --- /dev/null +++ b/app_flutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilder.kt b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilder.kt new file mode 100644 index 000000000..56e0716e6 --- /dev/null +++ b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilder.kt @@ -0,0 +1,19 @@ +package net.xzos.upgradeall + +import org.json.JSONObject + +object GetterBridgeRequestBuilder { + fun readOperationRequest(args: Map<*, *>): String = operationRequest(args) + + fun runtimeOperationRequest(args: Map<*, *>): String = operationRequest(args) + + private fun operationRequest(args: Map<*, *>): String { + val operation = args["operation"] as? String + ?: throw IllegalArgumentException("operation is required") + val payload = args["payload"] as? Map<*, *> ?: emptyMap() + return JSONObject() + .put("operation", operation) + .put("payload", JSONObject(payload)) + .toString() + } +} diff --git a/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparer.kt b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparer.kt new file mode 100644 index 000000000..079cf2d49 --- /dev/null +++ b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparer.kt @@ -0,0 +1,86 @@ +package net.xzos.upgradeall + +import android.database.sqlite.SQLiteDatabase +import java.io.File + +internal data class PreparedLegacyRoomImport( + val found: Boolean, + val databasePath: String?, + val message: String, +) { + fun toMethodChannelResult(): Map = mapOf( + "found" to found, + "database_path" to databasePath, + "message" to message, + ) +} + +internal fun interface CopiedDatabaseCheckpointer { + fun checkpoint(database: File) +} + +internal class LegacyRoomImportPreparer( + private val checkpointer: CopiedDatabaseCheckpointer = AndroidSqliteCopiedDatabaseCheckpointer(), +) { + fun prepare(source: File, destination: File): PreparedLegacyRoomImport { + if (!source.exists()) { + return PreparedLegacyRoomImport( + found = false, + databasePath = null, + message = "No legacy Room database found", + ) + } + + copySqliteTriplet(source, destination) + checkpointer.checkpoint(destination) + + return PreparedLegacyRoomImport( + found = true, + databasePath = destination.absolutePath, + message = "Legacy Room database prepared", + ) + } + + private fun copySqliteTriplet(source: File, destination: File) { + destination.parentFile?.mkdirs() + SQLITE_SUFFIXES.forEach { suffix -> + val sourceFile = File(source.path + suffix) + val destinationFile = File(destination.path + suffix) + if (sourceFile.exists()) { + sourceFile.copyTo(destinationFile, overwrite = true) + } else if (destinationFile.exists()) { + destinationFile.delete() + } + } + } + + private companion object { + val SQLITE_SUFFIXES = listOf("", "-wal", "-shm") + } +} + +internal class AndroidSqliteCopiedDatabaseCheckpointer : CopiedDatabaseCheckpointer { + override fun checkpoint(database: File) { + val db = SQLiteDatabase.openDatabase( + database.path, + null, + SQLiteDatabase.OPEN_READWRITE, + ) + try { + db.rawQuery("PRAGMA wal_checkpoint(FULL)", null).use { cursor -> + while (cursor.moveToNext()) { + // Drain the pragma result so SQLite performs the checkpoint. + } + } + db.rawQuery("PRAGMA journal_mode=DELETE", null).use { cursor -> + while (cursor.moveToNext()) { + // Drain the pragma result and leave a standalone import DB. + } + } + } finally { + db.close() + } + File(database.path + "-wal").delete() + File(database.path + "-shm").delete() + } +} diff --git a/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/MainActivity.kt b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/MainActivity.kt new file mode 100644 index 000000000..218724794 --- /dev/null +++ b/app_flutter/android/app/src/main/kotlin/net/xzos/upgradeall/MainActivity.kt @@ -0,0 +1,275 @@ +package net.xzos.upgradeall + +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.util.concurrent.Executors +import net.xzos.upgradeall.getter.NativeLib +import org.json.JSONArray +import org.json.JSONObject + +class MainActivity : FlutterActivity() { + private val legacyMigrationExecutor = Executors.newSingleThreadExecutor() + private val getterBridgeExecutor = Executors.newSingleThreadExecutor() + private val mainHandler = Handler(Looper.getMainLooper()) + @Volatile + private var runtimeEventSink: EventChannel.EventSink? = null + private val nativeLib by lazy { NativeLib() } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + RUNTIME_NOTIFICATION_CHANNEL, + ).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + runtimeEventSink = events + emitRuntimeNotifications() + } + + override fun onCancel(arguments: Any?) { + runtimeEventSink = null + } + }, + ) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + GETTER_BRIDGE_CHANNEL, + ).setMethodCallHandler { call, result -> + when (call.method) { + "initializeBridge" -> runGetterBridge(result) { + nativeLib.initializeBridge(applicationContext) + } + + "previewInstalledAutogen" -> runGetterBridge(result) { + nativeLib.previewInstalledAutogen( + applicationContext, + previewInstalledAutogenRequest(call), + ) + } + + "applyInstalledAutogen" -> runGetterBridge(result) { + nativeLib.applyInstalledAutogen(applyInstalledAutogenRequest(call)) + } + + "importLegacyRoomDatabase" -> runGetterBridge(result) { + nativeLib.importLegacyRoomDatabase(importLegacyRoomDatabaseRequest(call)) + } + + "legacyReportList" -> runGetterBridge(result) { + nativeLib.legacyReportList(legacyReportListRequest()) + } + + "readOperation" -> runGetterBridge(result) { + nativeLib.readOperation(readOperationRequest(call)) + } + + "runtimeOperation" -> runGetterBridge(result, emitRuntimeNotifications = true) { + nativeLib.runtimeOperation(runtimeOperationRequest(call)) + } + + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + LEGACY_MIGRATION_CHANNEL, + ).setMethodCallHandler { call, result -> + when (call.method) { + "prepareLegacyRoomImport" -> { + legacyMigrationExecutor.execute { + try { + val candidate = prepareLegacyRoomImport() + mainHandler.post { result.success(candidate) } + } catch (error: Exception) { + mainHandler.post { + result.error( + "legacy.prepare_failed", + error.message ?: "Failed to prepare legacy Room database", + null, + ) + } + } + } + } + + else -> result.notImplemented() + } + } + } + + override fun onDestroy() { + legacyMigrationExecutor.shutdown() + getterBridgeExecutor.shutdown() + super.onDestroy() + } + + private fun runGetterBridge( + result: MethodChannel.Result, + emitRuntimeNotifications: Boolean = false, + operation: () -> String, + ) { + getterBridgeExecutor.execute { + try { + val response = operation() + val notifications = if (emitRuntimeNotifications) { + drainRuntimeNotificationEvents() + } else { + emptyList() + } + mainHandler.post { + result.success(response) + emitRuntimeNotifications(notifications) + } + } catch (error: UnsatisfiedLinkError) { + mainHandler.post { + result.error( + "bridge.native_unavailable", + error.message ?: "Getter native bridge is unavailable", + null, + ) + } + } catch (error: Exception) { + mainHandler.post { + result.error( + "bridge.call_failed", + error.message ?: "Getter native bridge call failed", + null, + ) + } + } + } + } + + private fun previewInstalledAutogenRequest(call: MethodCall): String { + val args = call.arguments as? Map<*, *> ?: emptyMap() + val scanOptions = args["scan_options"] as? Map<*, *> ?: args + return JSONObject() + .put("data_dir", getterDataDir().absolutePath) + .put( + "scan_options", + JSONObject() + .put( + "include_system_apps", + scanOptions["include_system_apps"] as? Boolean ?: false, + ) + .put( + "include_self", + scanOptions["include_self"] as? Boolean ?: false, + ), + ) + .toString() + } + + private fun applyInstalledAutogenRequest(call: MethodCall): String { + val args = call.arguments as? Map<*, *> ?: emptyMap() + val previewJson = args["preview_json"] as? String + ?: throw IllegalArgumentException("preview_json is required") + val acceptance = args["acceptance"] as? Map<*, *> + val packageIds = acceptance + ?.get("package_ids") + ?.let { value -> value as? Collection<*> } + ?.map { value -> value.toString() } + ?: emptyList() + return JSONObject() + .put("data_dir", getterDataDir().absolutePath) + .put("preview", JSONObject(previewJson)) + .put( + "acceptance", + JSONObject() + .put("mode", acceptance?.get("mode") as? String ?: "all") + .put("package_ids", JSONArray(packageIds)), + ) + .toString() + } + + private fun importLegacyRoomDatabaseRequest(call: MethodCall): String { + val args = call.arguments as? Map<*, *> ?: emptyMap() + val databasePath = args["database_path"] as? String + ?: throw IllegalArgumentException("database_path is required") + return JSONObject() + .put("data_dir", getterDataDir().absolutePath) + .put("database_path", databasePath) + .toString() + } + + private fun legacyReportListRequest(): String { + return JSONObject() + .put("data_dir", getterDataDir().absolutePath) + .toString() + } + + private fun readOperationRequest(call: MethodCall): String { + val args = call.arguments as? Map<*, *> + ?: throw IllegalArgumentException("read operation arguments are required") + return JSONObject(GetterBridgeRequestBuilder.readOperationRequest(args)) + .put("data_dir", getterDataDir().absolutePath) + .toString() + } + + private fun runtimeOperationRequest(call: MethodCall): String { + val args = call.arguments as? Map<*, *> + ?: throw IllegalArgumentException("runtime operation arguments are required") + return JSONObject(GetterBridgeRequestBuilder.runtimeOperationRequest(args)) + .put("data_dir", getterDataDir().absolutePath) + .toString() + } + + private fun emitRuntimeNotifications() { + getterBridgeExecutor.execute { + val notifications = drainRuntimeNotificationEvents() + mainHandler.post { emitRuntimeNotifications(notifications) } + } + } + + private fun emitRuntimeNotifications(notifications: List) { + val sink = runtimeEventSink ?: return + for (notification in notifications) { + sink.success(notification) + } + } + + private fun drainRuntimeNotificationEvents(): List { + return try { + val envelope = JSONObject(nativeLib.drainRuntimeNotifications()) + if (!envelope.optBoolean("ok", false)) { + return emptyList() + } + val notifications = envelope + .getJSONObject("data") + .getJSONArray("notifications") + List(notifications.length()) { index -> notifications.getJSONObject(index).toString() } + } catch (_: UnsatisfiedLinkError) { + emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun getterDataDir(): File = File(filesDir, "getter") + + private fun prepareLegacyRoomImport(): Map { + val destination = File( + File(filesDir, "getter-imports/legacy-room"), + LEGACY_ROOM_DB_NAME, + ) + return LegacyRoomImportPreparer() + .prepare(getDatabasePath(LEGACY_ROOM_DB_NAME), destination) + .toMethodChannelResult() + } + + private companion object { + const val GETTER_BRIDGE_CHANNEL = "net.xzos.upgradeall/getter_bridge" + const val RUNTIME_NOTIFICATION_CHANNEL = "net.xzos.upgradeall/runtime_notifications" + const val LEGACY_MIGRATION_CHANNEL = "net.xzos.upgradeall/legacy_migration" + const val LEGACY_ROOM_DB_NAME = "app_metadata_database.db" + } +} diff --git a/app_flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/app_flutter/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..f74085f3f --- /dev/null +++ b/app_flutter/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app_flutter/android/app/src/main/res/drawable/launch_background.xml b/app_flutter/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..304732f88 --- /dev/null +++ b/app_flutter/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..db77bb4b7 Binary files /dev/null and b/app_flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..17987b79b Binary files /dev/null and b/app_flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..09d439148 Binary files /dev/null and b/app_flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d5f1c8d34 Binary files /dev/null and b/app_flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4d6372eeb Binary files /dev/null and b/app_flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app_flutter/android/app/src/main/res/values-night/styles.xml b/app_flutter/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..06952be74 --- /dev/null +++ b/app_flutter/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app_flutter/android/app/src/main/res/values/styles.xml b/app_flutter/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..cb1ef8805 --- /dev/null +++ b/app_flutter/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app_flutter/android/app/src/profile/AndroidManifest.xml b/app_flutter/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/app_flutter/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilderTest.kt b/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilderTest.kt new file mode 100644 index 000000000..37917868f --- /dev/null +++ b/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/GetterBridgeRequestBuilderTest.kt @@ -0,0 +1,62 @@ +package net.xzos.upgradeall + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class GetterBridgeRequestBuilderTest { + @Test + fun readOperationRequestPreservesOperationAndPayload() { + val json = JSONObject( + GetterBridgeRequestBuilder.readOperationRequest( + mapOf( + "operation" to "package_eval", + "payload" to mapOf("package_id" to "android/org.fdroid.fdroid"), + ), + ), + ) + + assertEquals("package_eval", json.getString("operation")) + assertEquals( + "android/org.fdroid.fdroid", + json.getJSONObject("payload").getString("package_id"), + ) + } + + @Test + fun runtimeOperationRequestPreservesOperationAndPayload() { + val json = JSONObject( + GetterBridgeRequestBuilder.runtimeOperationRequest( + mapOf( + "operation" to "task_get", + "payload" to mapOf("task_id" to "task-1"), + ), + ), + ) + + assertEquals("task_get", json.getString("operation")) + assertEquals("task-1", json.getJSONObject("payload").getString("task_id")) + } + + @Test + fun runtimeOperationRequestDefaultsMissingPayloadToEmptyObject() { + val json = JSONObject( + GetterBridgeRequestBuilder.runtimeOperationRequest( + mapOf("operation" to "task_list"), + ), + ) + + assertEquals("task_list", json.getString("operation")) + assertEquals(0, json.getJSONObject("payload").length()) + } + + @Test + fun runtimeOperationRequestRequiresOperation() { + val error = assertThrows(IllegalArgumentException::class.java) { + GetterBridgeRequestBuilder.runtimeOperationRequest(mapOf("payload" to emptyMap())) + } + + assertEquals("operation is required", error.message) + } +} diff --git a/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparerTest.kt b/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparerTest.kt new file mode 100644 index 000000000..1cb03d648 --- /dev/null +++ b/app_flutter/android/app/src/test/kotlin/net/xzos/upgradeall/LegacyRoomImportPreparerTest.kt @@ -0,0 +1,81 @@ +package net.xzos.upgradeall + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LegacyRoomImportPreparerTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun missingSourceReturnsNotFoundAndDoesNotCheckpoint() { + val source = File(temp.root, "source/app_metadata_database.db") + val destination = File(temp.root, "destination/app_metadata_database.db") + val checkpointer = RecordingCheckpointer() + + val result = LegacyRoomImportPreparer(checkpointer).prepare(source, destination) + + assertFalse(result.found) + assertNull(result.databasePath) + assertEquals("No legacy Room database found", result.message) + assertTrue(checkpointer.databases.isEmpty()) + assertFalse(destination.exists()) + } + + @Test + fun copiesExistingSqliteTripletAndCallsCheckpoint() { + val source = File(temp.root, "source/app_metadata_database.db") + val destination = File(temp.root, "destination/app_metadata_database.db") + source.writeTextWithParents("db") + File(source.path + "-wal").writeTextWithParents("wal") + File(source.path + "-shm").writeTextWithParents("shm") + val checkpointer = RecordingCheckpointer() + + val result = LegacyRoomImportPreparer(checkpointer).prepare(source, destination) + + assertTrue(result.found) + assertEquals(destination.absolutePath, result.databasePath) + assertEquals("Legacy Room database prepared", result.message) + assertEquals("db", destination.readText()) + assertEquals("wal", File(destination.path + "-wal").readText()) + assertEquals("shm", File(destination.path + "-shm").readText()) + assertEquals(listOf(destination), checkpointer.databases) + } + + @Test + fun removesStaleDestinationSidecarsWhenSourceSidecarsAreAbsent() { + val source = File(temp.root, "source/app_metadata_database.db") + val destination = File(temp.root, "destination/app_metadata_database.db") + source.writeTextWithParents("fresh-db") + destination.writeTextWithParents("old-db") + File(destination.path + "-wal").writeTextWithParents("stale-wal") + File(destination.path + "-shm").writeTextWithParents("stale-shm") + val checkpointer = RecordingCheckpointer() + + LegacyRoomImportPreparer(checkpointer).prepare(source, destination) + + assertEquals("fresh-db", destination.readText()) + assertFalse(File(destination.path + "-wal").exists()) + assertFalse(File(destination.path + "-shm").exists()) + assertEquals(listOf(destination), checkpointer.databases) + } + + private fun File.writeTextWithParents(text: String) { + parentFile?.mkdirs() + writeText(text) + } + + private class RecordingCheckpointer : CopiedDatabaseCheckpointer { + val databases = mutableListOf() + + override fun checkpoint(database: File) { + databases.add(database) + } + } +} diff --git a/app_flutter/android/build.gradle b/app_flutter/android/build.gradle new file mode 100644 index 000000000..775ef0080 --- /dev/null +++ b/app_flutter/android/build.gradle @@ -0,0 +1,46 @@ +import groovy.json.JsonSlurper + +buildscript { + ext.kotlin_version = '2.3.20' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +String findRustlsPlatformVerifierProject() { + def apiProxyManifest = file('../../core-getter/src/main/rust/api_proxy/Cargo.toml') + def dependencyText = providers.exec { + commandLine('cargo', 'metadata', '--format-version', '1', '--manifest-path', apiProxyManifest.path) + }.standardOutput.asText.get() + def dependencyJson = new JsonSlurper().parseText(dependencyText) + def manifestPath = file(dependencyJson.packages.find { it.name == 'rustls-platform-verifier-android' }.manifest_path) + return new File(manifestPath.parentFile, 'maven').path +} + +allprojects { + repositories { + google() + mavenCentral() + maven { + url = findRustlsPlatformVerifierProject() + metadataSources.artifact() + } + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/app_flutter/android/getter_bridge/build.gradle b/app_flutter/android/getter_bridge/build.gradle new file mode 100644 index 000000000..631a36991 --- /dev/null +++ b/app_flutter/android/getter_bridge/build.gradle @@ -0,0 +1,72 @@ +plugins { + id "com.android.library" + id "org.jetbrains.kotlin.android" + id "io.github.MatrixDev.android-rust" +} + +def resolveAndroidNdkPath() { + def envNdk = System.getenv("ANDROID_NDK_HOME") ?: System.getenv("ANDROID_NDK_ROOT") + if (envNdk != null && !envNdk.isBlank()) { + return envNdk + } + def localProperties = new Properties() + def localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { localProperties.load(it) } + def sdkDir = localProperties.getProperty("sdk.dir") + if (sdkDir != null && !sdkDir.isBlank()) { + return new File(sdkDir, "ndk/29.0.14206865").path + } + } + return null +} + +android { + namespace "net.xzos.upgradeall.getter.bridge" + compileSdkVersion 36 + ndkVersion "29.0.14206865" + def configuredNdkPath = resolveAndroidNdkPath() + if (configuredNdkPath != null) { + ndkPath configuredNdkPath + } + + defaultConfig { + minSdkVersion 23 + consumerProguardFiles file("../../../core-getter/consumer-rules.pro") + } + + sourceSets { + main.java.srcDirs += file("../../../core-getter/src/main/java/net/xzos/upgradeall/getter/platform") + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +androidRust { + minimumSupportedRustVersion = "1.62.1" + + module("api_proxy") { moduleConfig -> + moduleConfig.path = file("../../../core-getter/src/main/rust/api_proxy") + moduleConfig.targets = ["x86_64", "arm", "arm64"] + + moduleConfig.buildType("debug") { + it.profile = "dev" + } + + moduleConfig.buildType("release") { + it.profile = "release" + it.runTests = true + } + } +} + +dependencies { + implementation "rustls:rustls-platform-verifier:latest.release" +} diff --git a/app_flutter/android/getter_bridge/src/main/AndroidManifest.xml b/app_flutter/android/getter_bridge/src/main/AndroidManifest.xml new file mode 100644 index 000000000..94cbbcfc3 --- /dev/null +++ b/app_flutter/android/getter_bridge/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/app_flutter/android/getter_bridge/src/main/kotlin/net/xzos/upgradeall/getter/NativeLib.kt b/app_flutter/android/getter_bridge/src/main/kotlin/net/xzos/upgradeall/getter/NativeLib.kt new file mode 100644 index 000000000..e5b2ec144 --- /dev/null +++ b/app_flutter/android/getter_bridge/src/main/kotlin/net/xzos/upgradeall/getter/NativeLib.kt @@ -0,0 +1,31 @@ +package net.xzos.upgradeall.getter + +import android.content.Context + +class RunServerCallback(private val callback: (String) -> Unit) { + fun callback(url: String) { + callback.invoke(url) + } +} + +class NativeLib { + external fun runServer(context: Context, callback: RunServerCallback): String + external fun initializeBridge(context: Context): String + external fun previewInstalledAutogen(context: Context, requestJson: String): String + external fun applyInstalledAutogen(requestJson: String): String + external fun importLegacyRoomDatabase(requestJson: String): String + external fun legacyReportList(requestJson: String): String + external fun readOperation(requestJson: String): String + external fun runtimeOperation(requestJson: String): String + external fun drainRuntimeNotifications(): String + + fun runServerLambda(context: Context, callback: (String) -> Unit): String { + return runServer(context, RunServerCallback(callback)) + } + + companion object { + init { + System.loadLibrary("api_proxy") + } + } +} diff --git a/app_flutter/android/gradle.properties b/app_flutter/android/gradle.properties new file mode 100644 index 000000000..f8a16ee9f --- /dev/null +++ b/app_flutter/android/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true +# Flutter's Gradle plugin still expects AGP's legacy Android extension while +# Flutter 3.44 templates use AGP 9, so keep the template compatibility flags +# until Flutter removes them upstream. +android.newDsl=false +android.builtInKotlin=false +android.suppressUnsupportedCompileSdk=36 diff --git a/app_flutter/android/gradle/wrapper/gradle-wrapper.jar b/app_flutter/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..13372aef5 Binary files /dev/null and b/app_flutter/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/app_flutter/android/gradle/wrapper/gradle-wrapper.properties b/app_flutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..bdc0141f5 --- /dev/null +++ b/app_flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/app_flutter/android/gradlew b/app_flutter/android/gradlew new file mode 100755 index 000000000..9d82f7891 --- /dev/null +++ b/app_flutter/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/app_flutter/android/gradlew.bat b/app_flutter/android/gradlew.bat new file mode 100644 index 000000000..8a0b282aa --- /dev/null +++ b/app_flutter/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app_flutter/android/settings.gradle b/app_flutter/android/settings.gradle new file mode 100644 index 000000000..8d03a57a0 --- /dev/null +++ b/app_flutter/android/settings.gradle @@ -0,0 +1,54 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + def ensureCargoBinPath = { + def localPropertiesFile = file("local.properties") + def properties = new Properties() + if (localPropertiesFile.exists()) { + localPropertiesFile.withInputStream { properties.load(it) } + } + def configuredCargoBin = properties.getProperty("cargo.bin") + if (configuredCargoBin != null && !configuredCargoBin.isBlank()) { + return + } + def pathEntries = (System.getenv("PATH") ?: "").split(File.pathSeparator) + def cargoFile = pathEntries.collect { new File(it, "cargo") }.find { it.isFile() && it.canExecute() } + if (cargoFile == null) { + return + } + properties.setProperty("cargo.bin", cargoFile.parentFile.absolutePath) + localPropertiesFile.withOutputStream { properties.store(it, "Generated by Gradle so android-rust can find Cargo binaries") } + } + ensureCargoBinPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + plugins { + id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false + id "com.android.library" version "9.0.1" apply false + id "org.jetbrains.kotlin.android" version "2.3.20" apply false + id "io.github.MatrixDev.android-rust" version "0.6.0" apply false + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "9.0.1" apply false +} + +include ":app" +include ":getter_bridge" +project(":getter_bridge").projectDir = file("getter_bridge") diff --git a/app_flutter/dev_test/cli_getter_adapter_test.dart b/app_flutter/dev_test/cli_getter_adapter_test.dart new file mode 100644 index 000000000..0525279f8 --- /dev/null +++ b/app_flutter/dev_test/cli_getter_adapter_test.dart @@ -0,0 +1,228 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:upgradeall/cli_getter_adapter.dart'; + +void main() { + test('CliGetterAdapter imports a direct legacy Room database', () async { + final getterCli = Platform.environment['GETTER_CLI_BIN']; + if (getterCli == null || getterCli.isEmpty) { + fail('GETTER_CLI_BIN must point to the built getter-cli binary'); + } + + final temp = Directory.systemTemp.createTempSync('upgradeall-getter-cli-'); + addTearDown(() => temp.deleteSync(recursive: true)); + + final dataDir = Directory('${temp.path}/data')..createSync(); + final legacyDb = _createLegacyRoomDatabase(temp); + final adapter = CliGetterAdapter( + executable: getterCli, + dataDir: dataDir.path, + ); + + adapter.initialize(); + final result = await adapter.importLegacyRoomDatabase(legacyDb.path); + + expect(result.alreadyImported, isFalse); + expect(result.importedRecords, 1); + expect(result.sourceCounts?.appRows, 1); + expect(result.sourceCounts?.extraAppRows, 1); + final tracked = result.trackedPackages.singleWhere( + (package) => package.id == 'android/org.fdroid.fdroid', + ); + expect(tracked.favorite, isTrue); + expect(tracked.pinVersion, '1.20.0'); + expect(tracked.packageResolution, 'missing_package_definition'); + + final reports = await adapter.readMigrationReports(); + expect( + reports.singleWhere((report) => report.code == 'migration.imported').ok, + isTrue, + ); + }); + + test( + 'CliGetterAdapter reads real getter repository and tracked state', + () async { + final getterCli = Platform.environment['GETTER_CLI_BIN']; + if (getterCli == null || getterCli.isEmpty) { + fail('GETTER_CLI_BIN must point to the built getter-cli binary'); + } + + final temp = Directory.systemTemp.createTempSync( + 'upgradeall-getter-cli-', + ); + addTearDown(() => temp.deleteSync(recursive: true)); + + final dataDir = Directory('${temp.path}/data')..createSync(); + final repoDir = _createFixtureRepository(temp, 'official'); + final bundle = _createLegacyBundle(temp); + final legacyDb = _createLegacyRoomDatabase(temp); + final adapter = CliGetterAdapter( + executable: getterCli, + dataDir: dataDir.path, + ); + + adapter.initialize(); + _runGetter(getterCli, dataDir.path, [ + 'repo', + 'add', + 'official', + repoDir.path, + '--priority', + '0', + ]); + _runGetter(getterCli, dataDir.path, [ + 'legacy', + 'import-room-bundle', + bundle.path, + ]); + final repositories = adapter.listRepositories(); + expect(repositories.map((repo) => repo.id), contains('official')); + expect( + repositories.singleWhere((repo) => repo.id == 'official').priority, + 0, + ); + + final trackedPackages = adapter.listTrackedPackages(); + final tracked = trackedPackages.singleWhere( + (package) => package.id == 'android/org.fdroid.fdroid', + ); + expect(tracked.favorite, isTrue); + expect(tracked.pinVersion, '1.20.0'); + expect(tracked.packageResolution, 'official_repository_package'); + + final evaluated = adapter.evaluatePackage( + 'android/org.fdroid.fdroid', + repositoryId: 'official', + ); + expect(evaluated.name, 'F-Droid'); + expect(evaluated.repositoryId, 'official'); + expect(evaluated.hasFreeNetworkWarning, isTrue); + + final reports = await adapter.readMigrationReports(); + expect( + reports.singleWhere((report) => report.code == 'migration.imported').ok, + isTrue, + ); + + final alreadyImported = await adapter.importLegacyRoomDatabase( + legacyDb.path, + ); + expect(alreadyImported.alreadyImported, isTrue); + expect(alreadyImported.importedRecords, 0); + expect( + alreadyImported.trackedPackages.map((package) => package.id), + contains('android/org.fdroid.fdroid'), + ); + + final snapshot = await adapter.loadSnapshot(); + expect(snapshot.status, 'Getter CLI ready'); + expect( + snapshot.repositories.map((repo) => repo.id), + contains('official'), + ); + final app = snapshot.apps.singleWhere( + (app) => app.id == 'android/org.fdroid.fdroid', + ); + expect(app.name, 'F-Droid'); + expect(app.installedVersion, 'unknown'); + expect(app.hasFreeNetworkWarning, isTrue); + }, + ); +} + +Directory _createFixtureRepository(Directory temp, String repoId) { + final repoDir = Directory('${temp.path}/repo-$repoId')..createSync(); + Directory('${repoDir.path}/packages/android').createSync(recursive: true); + Directory('${repoDir.path}/lib').createSync(); + Directory('${repoDir.path}/templates').createSync(); + File('${repoDir.path}/repo.toml').writeAsStringSync(''' +id = "$repoId" +name = "Fixture $repoId" +priority = 0 +api_version = "getter.repo.v1" +'''); + File( + '${repoDir.path}/packages/android/org.fdroid.fdroid.lua', + ).writeAsStringSync(''' +return package_def { + id = "android/org.fdroid.fdroid", + name = "F-Droid", + installed = { + { kind = "android_package", package_name = "org.fdroid.fdroid" }, + }, + permissions = { free_network = true }, +} +'''); + return repoDir; +} + +File _createLegacyBundle(Directory temp) { + return File('${temp.path}/legacy-bundle.json')..writeAsStringSync(''' +{ + "format": "upgradeall-legacy-room-bundle", + "version": 17, + "apps": [ + { + "kind": "android", + "installed_id": "org.fdroid.fdroid", + "official_package_available": true, + "pin_version": "1.20.0", + "favorite": true + } + ] +} +'''); +} + +File _createLegacyRoomDatabase(Directory temp) { + final db = File('${temp.path}/app_metadata_database.db'); + final result = Process.runSync('python3', [ + '-c', + r''' +import sqlite3 +import sys +path = sys.argv[1] +conn = sqlite3.connect(path) +conn.execute('PRAGMA user_version = 17') +conn.execute('CREATE TABLE app (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, app_id TEXT NOT NULL, ignore_version_number TEXT, star INTEGER)') +conn.execute('CREATE TABLE extra_app (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, mark_version_number TEXT)') +app_id = '{"android_app_package":"org.fdroid.fdroid"}' +conn.execute( + 'INSERT INTO app(id, name, app_id, ignore_version_number, star) VALUES (1, ?, ?, ?, ?)', + ('F-Droid', app_id, '1.10.0', 1), +) +conn.execute( + 'INSERT INTO extra_app(id, app_id, mark_version_number) VALUES (1, ?, ?)', + (app_id, '1.20.0'), +) +conn.commit() +conn.close() +''', + db.path, + ]); + if (result.exitCode != 0) { + fail( + 'failed to create legacy Room DB fixture\n' + 'stdout:\n${result.stdout}\n' + 'stderr:\n${result.stderr}', + ); + } + return db; +} + +void _runGetter(String getterCli, String dataDir, List args) { + final result = Process.runSync(getterCli, [ + '--data-dir', + dataDir, + ...args, + ]); + if (result.exitCode != 0) { + fail( + 'getter ${args.join(' ')} failed with ${result.exitCode}\n' + 'stdout:\n${result.stdout}\n' + 'stderr:\n${result.stderr}', + ); + } +} diff --git a/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db new file mode 100644 index 000000000..4a3492ca9 Binary files /dev/null and b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db differ diff --git a/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-shm b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-shm new file mode 100644 index 000000000..7a91c4378 Binary files /dev/null and b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-shm differ diff --git a/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-wal b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-wal new file mode 100644 index 000000000..09990d909 Binary files /dev/null and b/app_flutter/integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-wal differ diff --git a/app_flutter/integration_test/native_bridge_test.dart b/app_flutter/integration_test/native_bridge_test.dart new file mode 100644 index 000000000..941ae88e6 --- /dev/null +++ b/app_flutter/integration_test/native_bridge_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:upgradeall/getter_adapter.dart'; +import 'package:upgradeall/legacy_migration_platform.dart'; +import 'package:upgradeall/native_getter_adapter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('native bridge imports a copied legacy Room database', + (tester) async { + await _resetAppData(); + await _installLegacyRoomFixture(); + + final candidate = await const MethodChannelLegacyMigrationPlatform() + .prepareLegacyRoomImport(); + + expect(candidate.found, isTrue); + expect(candidate.databasePath, isNotNull); + expect(candidate.databasePath, contains('/getter-imports/legacy-room/')); + expect(File(candidate.databasePath!).existsSync(), isTrue); + + const adapter = MethodChannelGetterAdapter(); + final result = + await adapter.importLegacyRoomDatabase(candidate.databasePath!); + + expect(result.alreadyImported, isFalse); + expect(result.importedRecords, 1); + expect(result.trackedPackages, hasLength(1)); + expect(result.trackedPackages.single.id, 'android/org.fdroid.fdroid'); + expect(result.trackedPackages.single.favorite, isTrue); + expect(result.trackedPackages.single.pinVersion, '1.20.0'); + expect(result.sourceCounts?.appRows, 1); + expect(result.sourceCounts?.extraAppRows, 1); + + final reports = await adapter.readMigrationReports(); + expect( + reports.map((report) => report.code), contains('migration.imported')); + }); + + testWidgets('native bridge previews and applies installed autogen for self', + (tester) async { + await _resetAppData(); + + const adapter = MethodChannelGetterAdapter(); + final preview = await adapter.previewInstalledAutogen( + options: const InstalledAutogenScanOptions(includeSelf: true), + ); + + expect(preview.scanStats, isNotNull); + expect(preview.scanStats!.totalSeen, greaterThan(0)); + expect( + preview.candidates.map((candidate) => candidate.packageId), + contains(_selfPackageId), + ); + + final result = await adapter.applyInstalledAutogen( + preview, + acceptedPackageIds: const [_selfPackageId], + ); + + expect(result.appliedCount, 1); + expect( + result.applied.map((package) => package.packageId), + contains(_selfPackageId), + ); + }); +} + +const _debugPackageName = 'net.xzos.upgradeall.debug'; +const _selfPackageId = 'android/$_debugPackageName'; +const _legacyDbName = 'app_metadata_database.db'; +const _legacyFixtureDir = 'integration_test/fixtures/legacy_room_v17_wal'; + +Directory get _packageDataDir => Directory('/data/user/0/$_debugPackageName'); +Directory get _databasesDir => Directory('${_packageDataDir.path}/databases'); +Directory get _filesDir => Directory('${_packageDataDir.path}/files'); +File get _legacyDatabase => File('${_databasesDir.path}/$_legacyDbName'); + +Future _resetAppData() async { + for (final path in [ + '${_filesDir.path}/getter', + '${_filesDir.path}/getter-imports', + ]) { + final dir = Directory(path); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + for (final file in [ + _legacyDatabase, + File('${_legacyDatabase.path}-wal'), + File('${_legacyDatabase.path}-shm'), + ]) { + if (await file.exists()) { + await file.delete(); + } + } +} + +Future _installLegacyRoomFixture() async { + await _databasesDir.create(recursive: true); + for (final suffix in ['', '-wal', '-shm']) { + final asset = + await rootBundle.load('$_legacyFixtureDir/$_legacyDbName$suffix'); + await File('${_legacyDatabase.path}$suffix').writeAsBytes( + asset.buffer.asUint8List(asset.offsetInBytes, asset.lengthInBytes), + flush: true, + ); + } +} diff --git a/app_flutter/lib/cli_getter_adapter.dart b/app_flutter/lib/cli_getter_adapter.dart new file mode 100644 index 000000000..4042b0110 --- /dev/null +++ b/app_flutter/lib/cli_getter_adapter.dart @@ -0,0 +1,332 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'getter_adapter.dart'; + +class CliGetterAdapter implements GetterAdapter { + const CliGetterAdapter({ + required this.executable, + required this.dataDir, + this.environment = const {}, + }); + + final String executable; + final String dataDir; + final Map environment; + + @override + bool get supportsLegacyRoomImport => true; + + @override + bool get supportsInstalledAutogen => false; + + @override + void initialize() { + _runGetter(const ['init']); + } + + @override + List listRepositories() { + final json = _runGetter(const ['repo', 'list']); + final repositories = _asList(_data(json)['repositories'], 'repositories'); + return repositories.map(_repositoryFromJson).toList(growable: false); + } + + @override + List listTrackedPackages() { + final json = _runGetter(const ['app', 'list']); + final apps = _asList(_data(json)['apps'], 'apps'); + return apps.map(_trackedPackageFromJson).toList(growable: false); + } + + @override + PackageEvaluation evaluatePackage(String packageId, {String? repositoryId}) { + final args = ['package', 'eval', packageId]; + if (repositoryId != null) { + args.addAll(['--repo', repositoryId]); + } + final json = _runGetter(args); + final package = _asMap(_data(json)['package'], 'package'); + return _packageEvaluationFromJson(package); + } + + @override + Future> readMigrationReports() async { + final json = _runGetter(const ['legacy', 'report-list']); + final reports = _asList(_data(json)['reports'], 'reports'); + return reports + .map( + (report) => MigrationReportSummary.fromJson(_asMap(report, 'report')), + ) + .toList(growable: false); + } + + @override + Future importLegacyRoomDatabase( + String databasePath, + ) async { + final json = _runGetter(['legacy', 'import-room-db', databasePath]); + return LegacyMigrationImportResult.fromJson(_data(json)); + } + + @override + Future previewInstalledAutogen({ + InstalledAutogenScanOptions options = const InstalledAutogenScanOptions(), + }) async { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter cannot scan Android installed inventory', + ), + ); + } + + @override + Future applyInstalledAutogen( + InstalledAutogenPreview preview, { + List? acceptedPackageIds, + }) async { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter cannot apply Android installed autogen previews', + ), + ); + } + + @override + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }) async { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter does not host a process-lifetime runtime', + ), + ); + } + + @override + Future submitRuntimeAction(String actionId) { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter does not host a process-lifetime runtime', + ), + ); + } + + @override + Stream runtimeNotificationEnvelopes() { + return const Stream.empty(); + } + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter does not host a process-lifetime runtime', + ), + ); + } + + @override + Future getRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future startRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future pauseRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future resumeRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future cancelRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future retryRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future removeRuntimeTask(String taskId) => + _unsupportedRuntimeTask(); + + @override + Future sendRuntimeUserResult( + String taskId, + RuntimeUserResult result, { + String? reason, + }) => _unsupportedRuntimeTask(); + + @override + Future> cleanRuntimeTasks({ + RuntimeTaskCleanMode mode = RuntimeTaskCleanMode.defaultMode, + }) { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter does not host a process-lifetime runtime', + ), + ); + } + + Future _unsupportedRuntimeTask() { + throw const GetterBridgeException( + GetterError( + code: 'bridge.unsupported', + message: 'CLI adapter does not host a process-lifetime runtime', + ), + ); + } + + @override + Future loadSnapshot() async { + initialize(); + final repositories = listRepositories(); + final trackedPackages = listTrackedPackages(); + final apps = trackedPackages + .map((tracked) { + final evaluated = evaluatePackage( + tracked.id, + repositoryId: tracked.repositoryId, + ); + return AppSummary( + id: tracked.id, + name: evaluated.name, + installedVersion: 'unknown', + latestVersion: 'unknown', + hasFreeNetworkWarning: evaluated.hasFreeNetworkWarning, + ); + }) + .toList(growable: false); + + return GetterSnapshot( + status: 'Getter CLI ready', + updateCount: 0, + apps: apps, + repositories: repositories, + ); + } + + Map _runGetter(List commandArgs) { + final result = Process.runSync(executable, [ + '--data-dir', + dataDir, + ...commandArgs, + ], environment: environment.isEmpty ? null : environment); + final stdoutText = result.stdout.toString(); + final decoded = stdoutText.trim().isEmpty + ? {} + : _asMap(jsonDecode(stdoutText), 'getter stdout'); + if (result.exitCode != 0 || decoded['ok'] != true) { + final error = _errorFromEnvelope(decoded); + throw GetterBridgeException(error, exitCode: result.exitCode); + } + return decoded; + } +} + +Map _data(Map envelope) { + return _asMap(envelope['data'], 'data'); +} + +GetterError _errorFromEnvelope(Map envelope) { + final error = _asMap(envelope['error'], 'error'); + return GetterError( + code: _asString(error['code'], 'error.code'), + message: _asString(error['message'], 'error.message'), + detail: error['detail'] as String?, + ); +} + +RepositorySummary _repositoryFromJson(Object? value) { + final json = _asMap(value, 'repository'); + return RepositorySummary( + id: _asString(json['id'], 'repository.id'), + priority: _asInt(json['priority'], 'repository.priority'), + ); +} + +TrackedPackageSummary _trackedPackageFromJson(Object? value) { + final json = _asMap(value, 'tracked package'); + return TrackedPackageSummary( + id: _asString(json['id'], 'tracked.id'), + enabled: _asBool(json['enabled'], 'tracked.enabled'), + favorite: _asBool(json['favorite'], 'tracked.favorite'), + pinVersion: json['pin_version'] as String?, + repositoryId: json['repository_id'] as String?, + packageResolution: _asString( + json['package_resolution'], + 'tracked.package_resolution', + ), + ); +} + +PackageEvaluation _packageEvaluationFromJson(Object? value) { + final json = _asMap(value, 'package'); + final permissions = _asMap(json['permissions'], 'package.permissions'); + return PackageEvaluation( + id: _asString(json['id'], 'package.id'), + repositoryId: _asString(json['repository'], 'package.repository'), + name: _asString(json['name'], 'package.name'), + hasFreeNetworkWarning: _asBool( + permissions['free_network'], + 'package.permissions.free_network', + ), + ); +} + +Map _asMap(Object? value, String name) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + throw FormatException('$name should be a JSON object'); +} + +List _asList(Object? value, String name) { + if (value is List) { + return value; + } + if (value is List) { + return value.cast(); + } + throw FormatException('$name should be a JSON array'); +} + +String _asString(Object? value, String name) { + if (value is String) { + return value; + } + throw FormatException('$name should be a string'); +} + +int _asInt(Object? value, String name) { + if (value is int) { + return value; + } + throw FormatException('$name should be an integer'); +} + +bool _asBool(Object? value, String name) { + if (value is bool) { + return value; + } + throw FormatException('$name should be a boolean'); +} diff --git a/app_flutter/lib/fake_getter_adapter.dart b/app_flutter/lib/fake_getter_adapter.dart new file mode 100644 index 000000000..0934f8a95 --- /dev/null +++ b/app_flutter/lib/fake_getter_adapter.dart @@ -0,0 +1 @@ +export 'getter_adapter.dart' show FakeGetterAdapter; diff --git a/app_flutter/lib/getter_adapter.dart b/app_flutter/lib/getter_adapter.dart new file mode 100644 index 000000000..8272f4029 --- /dev/null +++ b/app_flutter/lib/getter_adapter.dart @@ -0,0 +1,1263 @@ +/// Getter-facing UI bridge contracts for the Flutter shell. +/// +/// These DTOs are transport/rendering shapes. Product decisions such as +/// repository overlay resolution, update selection, Lua validation, migration +/// mapping, and storage behavior belong in Rust getter. +abstract interface class GetterAdapter { + bool get supportsLegacyRoomImport; + + bool get supportsInstalledAutogen; + + void initialize(); + + List listRepositories(); + + List listTrackedPackages(); + + PackageEvaluation evaluatePackage(String packageId, {String? repositoryId}); + + Future> readMigrationReports(); + + Future importLegacyRoomDatabase( + String databasePath, + ); + + Future previewInstalledAutogen({ + InstalledAutogenScanOptions options = const InstalledAutogenScanOptions(), + }); + + Future applyInstalledAutogen( + InstalledAutogenPreview preview, { + List? acceptedPackageIds, + }); + + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }); + + Future submitRuntimeAction(String actionId); + + Stream runtimeNotificationEnvelopes(); + + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }); + + Future getRuntimeTask(String taskId); + + Future startRuntimeTask(String taskId); + + Future pauseRuntimeTask(String taskId); + + Future resumeRuntimeTask(String taskId); + + Future cancelRuntimeTask(String taskId); + + Future retryRuntimeTask(String taskId); + + Future removeRuntimeTask(String taskId); + + Future sendRuntimeUserResult( + String taskId, + RuntimeUserResult result, { + String? reason, + }); + + Future> cleanRuntimeTasks({ + RuntimeTaskCleanMode mode = RuntimeTaskCleanMode.defaultMode, + }); + + Future loadSnapshot(); +} + +class FakeGetterAdapter implements GetterAdapter { + const FakeGetterAdapter(); + + static const _snapshot = GetterSnapshot( + status: 'Fake getter ready', + updateCount: 0, + apps: [ + AppSummary( + id: 'android/org.fdroid.fdroid', + name: 'F-Droid', + installedVersion: '1.20.0', + latestVersion: '1.20.0', + hasFreeNetworkWarning: true, + ), + ], + repositories: [ + RepositorySummary(id: 'local', priority: 100), + RepositorySummary(id: 'official', priority: 0), + RepositorySummary(id: 'local_autogen', priority: -1), + ], + ); + + @override + bool get supportsLegacyRoomImport => false; + + @override + bool get supportsInstalledAutogen => true; + + @override + void initialize() {} + + @override + List listRepositories() => _snapshot.repositories; + + @override + List listTrackedPackages() { + return const [ + TrackedPackageSummary( + id: 'android/org.fdroid.fdroid', + enabled: true, + favorite: false, + pinVersion: null, + repositoryId: 'official', + packageResolution: 'official_repository_package', + ), + ]; + } + + @override + PackageEvaluation evaluatePackage(String packageId, {String? repositoryId}) { + if (packageId != 'android/org.fdroid.fdroid') { + throw const GetterBridgeException( + GetterError( + code: 'package.not_found', + message: 'Fake package not found', + ), + ); + } + return const PackageEvaluation( + id: 'android/org.fdroid.fdroid', + repositoryId: 'official', + name: 'F-Droid', + hasFreeNetworkWarning: true, + ); + } + + @override + Future> readMigrationReports() async { + return const []; + } + + @override + Future importLegacyRoomDatabase( + String databasePath, + ) async { + throw const GetterBridgeException( + GetterError( + code: 'bridge.not_connected', + message: 'Getter migration import bridge is not connected', + ), + ); + } + + @override + Future previewInstalledAutogen({ + InstalledAutogenScanOptions options = const InstalledAutogenScanOptions(), + }) async { + return InstalledAutogenPreview.fromJson(const { + 'operation': 'installed.preview', + 'target_repo_id': 'local_autogen', + 'target_repo_path': '/fake/getter/repositories/local_autogen', + 'scan': { + 'stats': { + 'total_seen': 3, + 'returned': 1, + 'filtered_system': 1, + 'filtered_self': 1, + }, + 'diagnostics': [], + }, + 'summary': { + 'candidate_count': 1, + 'skipped_count': 1, + 'write_count': 1, + 'delete_count': 0, + }, + 'candidates': [ + { + 'package_id': 'android/com.example.autogen', + 'kind': 'android', + 'display_name': 'Example Autogen', + 'installed_target': { + 'kind': 'android_package', + 'package_name': 'com.example.autogen', + }, + 'action': 'create', + 'output_relative_path': 'packages/android/com.example.autogen.lua', + 'content_hash': 'fnv1a64:fake', + 'content': '-- fake generated content', + }, + ], + 'skipped': [ + { + 'package_id': 'android/org.fdroid.fdroid', + 'reason': 'covered_by_higher_priority_repo', + 'covering_repo_id': 'official', + }, + ], + 'diagnostics': [], + }); + } + + @override + Future applyInstalledAutogen( + InstalledAutogenPreview preview, { + List? acceptedPackageIds, + }) async { + return InstalledAutogenApplyResult.fromJson(const { + 'target_repo_id': 'local_autogen', + 'target_repo_path': '/fake/getter/repositories/local_autogen', + 'applied_count': 1, + 'applied': [ + { + 'package_id': 'android/com.example.autogen', + 'output_relative_path': 'packages/android/com.example.autogen.lua', + }, + ], + 'preserved_to_local': [], + }); + } + + @override + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }) async { + return RuntimeUpdateCheckResult.fromJson({ + 'package': { + 'id': packageId, + 'name': 'F-Droid', + 'repository': repositoryId ?? 'official', + 'permissions': {'free_network': false}, + }, + 'update': { + 'network_required': false, + 'package_id': packageId, + 'installed_version': installedVersion, + 'effective_local_version': pinVersion ?? installedVersion, + 'policy': {'pin_version': pinVersion}, + 'status': 'update_available', + 'selected': { + 'package_id': packageId, + 'candidate': { + 'version': '1.2.0', + 'artifacts': [ + { + 'name': 'app.apk', + 'url': 'https://example.invalid/app.apk', + 'file_name': 'app.apk', + }, + ], + }, + 'artifact': { + 'name': 'app.apk', + 'url': 'https://example.invalid/app.apk', + 'file_name': 'app.apk', + }, + }, + 'actions': [ + { + 'type': 'download', + 'url': 'https://example.invalid/app.apk', + 'file_name': 'app.apk', + }, + ], + }, + 'action': { + 'action_id': 'action-fake', + 'package_id': packageId, + }, + }); + } + + @override + Future submitRuntimeAction(String actionId) async { + return RuntimeTaskSnapshot.fromJson(_runtimeTaskJson('task-1')); + } + + @override + Stream runtimeNotificationEnvelopes() { + return const Stream.empty(); + } + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) async { + return [ + RuntimeTaskSnapshot.fromJson(_runtimeTaskJson('task-1')), + ]; + } + + @override + Future getRuntimeTask(String taskId) async { + return RuntimeTaskSnapshot.fromJson(_runtimeTaskJson(taskId)); + } + + @override + Future startRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future pauseRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future resumeRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future cancelRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future retryRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future removeRuntimeTask(String taskId) => + getRuntimeTask(taskId); + + @override + Future sendRuntimeUserResult( + String taskId, + RuntimeUserResult result, { + String? reason, + }) => getRuntimeTask(taskId); + + @override + Future> cleanRuntimeTasks({ + RuntimeTaskCleanMode mode = RuntimeTaskCleanMode.defaultMode, + }) async { + return []; + } + + static Map _runtimeTaskJson(String taskId) { + return { + 'task_id': taskId, + 'package_id': 'android/org.fdroid.fdroid', + 'status': 'queued', + 'phase': {'category': 'queued'}, + 'progress': null, + 'capabilities': { + 'cancel': true, + 'pause': false, + 'resume': false, + 'retry': false, + }, + 'current_diagnostic': null, + 'updated_at': 1, + }; + } + + @override + Future loadSnapshot() async => _snapshot; +} + +class GetterSnapshot { + const GetterSnapshot({ + required this.status, + required this.updateCount, + required this.apps, + required this.repositories, + }); + + final String status; + final int updateCount; + final List apps; + final List repositories; +} + +class AppSummary { + const AppSummary({ + required this.id, + required this.name, + required this.installedVersion, + required this.latestVersion, + required this.hasFreeNetworkWarning, + }); + + final String id; + final String name; + final String installedVersion; + final String latestVersion; + final bool hasFreeNetworkWarning; +} + +class RepositorySummary { + const RepositorySummary({required this.id, required this.priority}); + + final String id; + final int priority; +} + +class TrackedPackageSummary { + const TrackedPackageSummary({ + required this.id, + required this.enabled, + required this.favorite, + required this.pinVersion, + required this.repositoryId, + required this.packageResolution, + }); + + factory TrackedPackageSummary.fromJson(Map json) { + return TrackedPackageSummary( + id: _jsonString(json['id'], 'tracked.id'), + enabled: _jsonBool(json['enabled'], 'tracked.enabled'), + favorite: _jsonBool(json['favorite'], 'tracked.favorite'), + pinVersion: _jsonOptionalString( + json['pin_version'], + 'tracked.pin_version', + ), + repositoryId: _jsonOptionalString( + json['repository_id'], + 'tracked.repository_id', + ), + packageResolution: _jsonString( + json['package_resolution'], + 'tracked.package_resolution', + ), + ); + } + + final String id; + final bool enabled; + final bool favorite; + final String? pinVersion; + final String? repositoryId; + final String packageResolution; +} + +class PackageEvaluation { + const PackageEvaluation({ + required this.id, + required this.repositoryId, + required this.name, + required this.hasFreeNetworkWarning, + }); + + final String id; + final String repositoryId; + final String name; + final bool hasFreeNetworkWarning; +} + +class MigrationReportSummary { + const MigrationReportSummary({ + required this.ok, + required this.code, + required this.message, + required this.importedRecords, + required this.trackedRecords, + }); + + factory MigrationReportSummary.fromJson(Map json) { + return MigrationReportSummary( + ok: _jsonBool(json['ok'], 'migration.ok'), + code: _jsonString(json['code'], 'migration.code'), + message: _jsonString(json['message'], 'migration.message'), + importedRecords: _jsonInt(json['imported_records'], 'migration.imported'), + trackedRecords: _jsonInt(json['tracked_records'], 'migration.tracked'), + ); + } + + final bool ok; + final String code; + final String message; + final int importedRecords; + final int trackedRecords; +} + +class LegacyMigrationImportResult { + const LegacyMigrationImportResult({ + required this.alreadyImported, + required this.importedRecords, + required this.trackedPackages, + required this.warnings, + required this.sourceCounts, + }); + + factory LegacyMigrationImportResult.fromJson(Map json) { + final warningsValue = json['warnings']; + final sourceCountsValue = json['source_counts']; + return LegacyMigrationImportResult( + alreadyImported: + _jsonOptionalBool( + json['already_imported'], + 'migration.already_imported', + ) ?? + false, + importedRecords: _jsonInt(json['imported_records'], 'migration.imported'), + trackedPackages: _jsonList(json['apps'], 'migration.apps') + .map( + (tracked) => TrackedPackageSummary.fromJson( + _jsonMap(tracked, 'migration.tracked_package'), + ), + ) + .toList(growable: false), + warnings: warningsValue == null + ? const [] + : _jsonList(warningsValue, 'migration.warnings') + .map( + (warning) => MigrationWarningSummary.fromJson( + _jsonMap(warning, 'migration.warning'), + ), + ) + .toList(growable: false), + sourceCounts: sourceCountsValue == null + ? null + : MigrationSourceCounts.fromJson( + _jsonMap(sourceCountsValue, 'migration.source_counts'), + ), + ); + } + + final bool alreadyImported; + final int importedRecords; + final List trackedPackages; + final List warnings; + final MigrationSourceCounts? sourceCounts; +} + +class MigrationWarningSummary { + const MigrationWarningSummary({required this.code, required this.message}); + + factory MigrationWarningSummary.fromJson(Map json) { + return MigrationWarningSummary( + code: _jsonString(json['code'], 'migration.warning.code'), + message: _jsonString(json['message'], 'migration.warning.message'), + ); + } + + final String code; + final String message; +} + +class MigrationSourceCounts { + const MigrationSourceCounts({ + required this.appRows, + required this.extraAppRows, + required this.hubRows, + required this.extraHubRows, + }); + + factory MigrationSourceCounts.fromJson(Map json) { + return MigrationSourceCounts( + appRows: _jsonInt(json['app_rows'], 'migration.source_counts.app_rows'), + extraAppRows: _jsonInt( + json['extra_app_rows'], + 'migration.source_counts.extra_app_rows', + ), + hubRows: _jsonInt(json['hub_rows'], 'migration.source_counts.hub_rows'), + extraHubRows: _jsonInt( + json['extra_hub_rows'], + 'migration.source_counts.extra_hub_rows', + ), + ); + } + + final int appRows; + final int extraAppRows; + final int hubRows; + final int extraHubRows; +} + +class RuntimeUpdateCheckResult { + const RuntimeUpdateCheckResult({ + required this.package, + required this.update, + required this.action, + }); + + factory RuntimeUpdateCheckResult.fromJson(Map json) { + return RuntimeUpdateCheckResult( + package: RuntimePackageSummary.fromJson( + _jsonMap(json['package'], 'runtime.package'), + ), + update: RuntimeUpdateSummary.fromJson( + _jsonMap(json['update'], 'runtime.update'), + ), + action: json['action'] == null + ? null + : RuntimeIssuedAction.fromJson( + _jsonMap(json['action'], 'runtime.action'), + ), + ); + } + + final RuntimePackageSummary package; + final RuntimeUpdateSummary update; + final RuntimeIssuedAction? action; +} + +class RuntimePackageSummary { + const RuntimePackageSummary({ + required this.id, + required this.name, + required this.repositoryId, + }); + + factory RuntimePackageSummary.fromJson(Map json) { + return RuntimePackageSummary( + id: _jsonString(json['id'], 'runtime.package.id'), + name: _jsonString(json['name'], 'runtime.package.name'), + repositoryId: _jsonString( + json['repository'], + 'runtime.package.repository', + ), + ); + } + + final String id; + final String name; + final String repositoryId; +} + +class RuntimeUpdateSummary { + const RuntimeUpdateSummary({ + required this.packageId, + required this.status, + required this.installedVersion, + required this.effectiveLocalVersion, + required this.selectedVersion, + required this.actions, + }); + + factory RuntimeUpdateSummary.fromJson(Map json) { + final selected = _jsonMapOrNull( + json['selected'], + 'runtime.update.selected', + ); + final candidate = selected == null + ? null + : _jsonMap(selected['candidate'], 'runtime.update.selected.candidate'); + return RuntimeUpdateSummary( + packageId: _jsonString(json['package_id'], 'runtime.update.package_id'), + status: _jsonString(json['status'], 'runtime.update.status'), + installedVersion: _jsonOptionalString( + json['installed_version'], + 'runtime.update.installed_version', + ), + effectiveLocalVersion: _jsonOptionalString( + json['effective_local_version'], + 'runtime.update.effective_local_version', + ), + selectedVersion: candidate == null + ? null + : _jsonString( + candidate['version'], + 'runtime.update.selected.version', + ), + actions: _jsonList(json['actions'], 'runtime.update.actions') + .map((action) => _jsonMap(action, 'runtime.update.action')) + .toList(growable: false), + ); + } + + final String packageId; + final String status; + final String? installedVersion; + final String? effectiveLocalVersion; + final String? selectedVersion; + final List> actions; +} + +class RuntimeIssuedAction { + const RuntimeIssuedAction({required this.actionId, required this.packageId}); + + factory RuntimeIssuedAction.fromJson(Map json) { + return RuntimeIssuedAction( + actionId: _jsonString(json['action_id'], 'runtime.action.action_id'), + packageId: _jsonString(json['package_id'], 'runtime.action.package_id'), + ); + } + + final String actionId; + final String packageId; +} + +class RuntimeTaskSnapshot { + const RuntimeTaskSnapshot({ + required this.taskId, + required this.packageId, + required this.status, + required this.phase, + required this.progress, + required this.capabilities, + required this.currentDiagnostic, + required this.updatedAt, + }); + + factory RuntimeTaskSnapshot.fromJson(Map json) { + return RuntimeTaskSnapshot( + taskId: _jsonString(json['task_id'], 'runtime.task.task_id'), + packageId: _jsonString(json['package_id'], 'runtime.task.package_id'), + status: _jsonString(json['status'], 'runtime.task.status'), + phase: RuntimeTaskPhase.fromJson( + _jsonMap(json['phase'], 'runtime.task.phase'), + ), + progress: json['progress'] == null + ? null + : RuntimeTaskProgress.fromJson( + _jsonMap(json['progress'], 'runtime.task.progress'), + ), + capabilities: RuntimeTaskCapabilities.fromJson( + _jsonMap(json['capabilities'], 'runtime.task.capabilities'), + ), + currentDiagnostic: json['current_diagnostic'] == null + ? null + : RuntimeTaskDiagnostic.fromJson( + _jsonMap( + json['current_diagnostic'], + 'runtime.task.current_diagnostic', + ), + ), + updatedAt: _jsonInt(json['updated_at'], 'runtime.task.updated_at'), + ); + } + + final String taskId; + final String packageId; + final String status; + final RuntimeTaskPhase phase; + final RuntimeTaskProgress? progress; + final RuntimeTaskCapabilities capabilities; + final RuntimeTaskDiagnostic? currentDiagnostic; + final int updatedAt; +} + +class RuntimeTaskPhase { + const RuntimeTaskPhase({required this.category, required this.reason}); + + factory RuntimeTaskPhase.fromJson(Map json) { + return RuntimeTaskPhase( + category: _jsonString(json['category'], 'runtime.task.phase.category'), + reason: _jsonOptionalString(json['reason'], 'runtime.task.phase.reason'), + ); + } + + final String category; + final String? reason; +} + +class RuntimeTaskProgress { + const RuntimeTaskProgress({ + required this.unit, + required this.current, + required this.total, + }); + + factory RuntimeTaskProgress.fromJson(Map json) { + return RuntimeTaskProgress( + unit: _jsonString(json['unit'], 'runtime.task.progress.unit'), + current: _jsonInt(json['current'], 'runtime.task.progress.current'), + total: json['total'] == null + ? null + : _jsonInt(json['total'], 'runtime.task.progress.total'), + ); + } + + final String unit; + final int current; + final int? total; +} + +class RuntimeTaskCapabilities { + const RuntimeTaskCapabilities({ + required this.cancel, + required this.pause, + required this.resume, + required this.retry, + }); + + factory RuntimeTaskCapabilities.fromJson(Map json) { + return RuntimeTaskCapabilities( + cancel: _jsonBool(json['cancel'], 'runtime.task.capabilities.cancel'), + pause: _jsonBool(json['pause'], 'runtime.task.capabilities.pause'), + resume: _jsonBool(json['resume'], 'runtime.task.capabilities.resume'), + retry: _jsonBool(json['retry'], 'runtime.task.capabilities.retry'), + ); + } + + final bool cancel; + final bool pause; + final bool resume; + final bool retry; +} + +class RuntimeTaskDiagnostic { + const RuntimeTaskDiagnostic({ + required this.code, + required this.message, + required this.severity, + }); + + factory RuntimeTaskDiagnostic.fromJson(Map json) { + return RuntimeTaskDiagnostic( + code: _jsonString(json['code'], 'runtime.task.diagnostic.code'), + message: _jsonString(json['message'], 'runtime.task.diagnostic.message'), + severity: _jsonString( + json['severity'], + 'runtime.task.diagnostic.severity', + ), + ); + } + + final String code; + final String message; + final String severity; +} + +enum RuntimeUserResult { + accepted, + rejected; + + String get wireName => switch (this) { + RuntimeUserResult.accepted => 'accepted', + RuntimeUserResult.rejected => 'rejected', + }; +} + +enum RuntimeTaskCleanMode { + defaultMode, + failed, + allInactive; + + String get wireName => switch (this) { + RuntimeTaskCleanMode.defaultMode => 'default', + RuntimeTaskCleanMode.failed => 'failed', + RuntimeTaskCleanMode.allInactive => 'all_inactive', + }; +} + +class RuntimeNotificationEnvelope { + const RuntimeNotificationEnvelope({required this.kind, required this.task}); + + factory RuntimeNotificationEnvelope.fromJson(Map json) { + final kind = _jsonString(json['kind'], 'runtime.notification.kind'); + return RuntimeNotificationEnvelope( + kind: kind, + task: kind == 'task_changed' + ? RuntimeTaskSnapshot.fromJson( + _jsonMap(json['task'], 'runtime.notification.task'), + ) + : null, + ); + } + + final String kind; + final RuntimeTaskSnapshot? task; +} + +class InstalledAutogenScanOptions { + const InstalledAutogenScanOptions({ + this.includeSystemApps = false, + this.includeSelf = false, + }); + + final bool includeSystemApps; + final bool includeSelf; + + Map toJson() => { + 'include_system_apps': includeSystemApps, + 'include_self': includeSelf, + }; +} + +class InstalledAutogenPreview { + InstalledAutogenPreview({ + required this.operation, + required this.targetRepoId, + required this.targetRepoPath, + required this.summary, + required this.candidates, + required this.skipped, + required this.diagnostics, + required this.scanStats, + required this.rawJson, + }); + + factory InstalledAutogenPreview.fromJson(Map json) { + final scan = _jsonMapOrNull(json['scan'], 'autogen.scan'); + return InstalledAutogenPreview( + operation: _jsonString(json['operation'], 'autogen.operation'), + targetRepoId: _jsonString( + json['target_repo_id'], + 'autogen.target_repo_id', + ), + targetRepoPath: _jsonOptionalString( + json['target_repo_path'], + 'autogen.target_repo_path', + ), + summary: AutogenSummary.fromJson( + _jsonMap(json['summary'], 'autogen.summary'), + ), + candidates: _jsonList(json['candidates'], 'autogen.candidates') + .map( + (candidate) => InstalledAutogenCandidate.fromJson( + _jsonMap(candidate, 'autogen.candidate'), + ), + ) + .toList(growable: false), + skipped: _jsonList(json['skipped'], 'autogen.skipped') + .map( + (skip) => + InstalledAutogenSkip.fromJson(_jsonMap(skip, 'autogen.skip')), + ) + .toList(growable: false), + diagnostics: + _jsonList( + scan?['diagnostics'] ?? json['diagnostics'], + 'autogen.diagnostics', + ) + .map( + (diagnostic) => PlatformDiagnosticSummary.fromJson( + _jsonMap(diagnostic, 'autogen.diagnostic'), + ), + ) + .toList(growable: false), + scanStats: scan == null || scan['stats'] == null + ? null + : InstalledAutogenScanStats.fromJson( + _jsonMap(scan['stats'], 'autogen.scan.stats'), + ), + rawJson: Map.unmodifiable(json), + ); + } + + final String operation; + final String targetRepoId; + final String? targetRepoPath; + final AutogenSummary summary; + final List candidates; + final List skipped; + final List diagnostics; + final InstalledAutogenScanStats? scanStats; + final Map rawJson; +} + +class AutogenSummary { + const AutogenSummary({ + required this.candidateCount, + required this.skippedCount, + required this.writeCount, + required this.deleteCount, + }); + + factory AutogenSummary.fromJson(Map json) { + return AutogenSummary( + candidateCount: _jsonInt( + json['candidate_count'], + 'autogen.summary.candidate_count', + ), + skippedCount: _jsonInt( + json['skipped_count'], + 'autogen.summary.skipped_count', + ), + writeCount: _jsonInt(json['write_count'], 'autogen.summary.write_count'), + deleteCount: _jsonInt( + json['delete_count'], + 'autogen.summary.delete_count', + ), + ); + } + + final int candidateCount; + final int skippedCount; + final int writeCount; + final int deleteCount; +} + +class InstalledAutogenCandidate { + const InstalledAutogenCandidate({ + required this.packageId, + required this.kind, + required this.displayName, + required this.action, + required this.outputRelativePath, + required this.contentHash, + required this.installedTarget, + }); + + factory InstalledAutogenCandidate.fromJson(Map json) { + return InstalledAutogenCandidate( + packageId: _jsonString( + json['package_id'], + 'autogen.candidate.package_id', + ), + kind: _jsonString(json['kind'], 'autogen.candidate.kind'), + displayName: _jsonString( + json['display_name'], + 'autogen.candidate.display_name', + ), + action: _jsonString(json['action'], 'autogen.candidate.action'), + outputRelativePath: _jsonString( + json['output_relative_path'], + 'autogen.candidate.output_relative_path', + ), + contentHash: _jsonString( + json['content_hash'], + 'autogen.candidate.content_hash', + ), + installedTarget: _jsonMap( + json['installed_target'], + 'autogen.candidate.installed_target', + ), + ); + } + + final String packageId; + final String kind; + final String displayName; + final String action; + final String outputRelativePath; + final String contentHash; + final Map installedTarget; +} + +class InstalledAutogenSkip { + const InstalledAutogenSkip({ + required this.packageId, + required this.reason, + required this.coveringRepoId, + }); + + factory InstalledAutogenSkip.fromJson(Map json) { + return InstalledAutogenSkip( + packageId: _jsonString(json['package_id'], 'autogen.skip.package_id'), + reason: _jsonString(json['reason'], 'autogen.skip.reason'), + coveringRepoId: _jsonOptionalString( + json['covering_repo_id'], + 'autogen.skip.covering_repo_id', + ), + ); + } + + final String packageId; + final String reason; + final String? coveringRepoId; +} + +class InstalledAutogenScanStats { + const InstalledAutogenScanStats({ + required this.totalSeen, + required this.returned, + required this.filteredSystem, + required this.filteredSelf, + }); + + factory InstalledAutogenScanStats.fromJson(Map json) { + return InstalledAutogenScanStats( + totalSeen: _jsonInt(json['total_seen'], 'autogen.scan.total_seen'), + returned: _jsonInt(json['returned'], 'autogen.scan.returned'), + filteredSystem: _jsonInt( + json['filtered_system'], + 'autogen.scan.filtered_system', + ), + filteredSelf: _jsonInt( + json['filtered_self'], + 'autogen.scan.filtered_self', + ), + ); + } + + final int totalSeen; + final int returned; + final int filteredSystem; + final int filteredSelf; +} + +class PlatformDiagnosticSummary { + const PlatformDiagnosticSummary({ + required this.code, + required this.message, + required this.detail, + }); + + factory PlatformDiagnosticSummary.fromJson(Map json) { + return PlatformDiagnosticSummary( + code: _jsonString(json['code'], 'autogen.diagnostic.code'), + message: _jsonString(json['message'], 'autogen.diagnostic.message'), + detail: _jsonOptionalString(json['detail'], 'autogen.diagnostic.detail'), + ); + } + + final String code; + final String message; + final String? detail; +} + +class InstalledAutogenApplyResult { + InstalledAutogenApplyResult({ + required this.targetRepoId, + required this.targetRepoPath, + required this.appliedCount, + required this.applied, + required this.preservedToLocal, + }); + + factory InstalledAutogenApplyResult.fromJson(Map json) { + return InstalledAutogenApplyResult( + targetRepoId: _jsonString( + json['target_repo_id'], + 'autogen.apply.target_repo_id', + ), + targetRepoPath: _jsonOptionalString( + json['target_repo_path'], + 'autogen.apply.target_repo_path', + ), + appliedCount: _jsonInt( + json['applied_count'], + 'autogen.apply.applied_count', + ), + applied: _jsonList(json['applied'], 'autogen.apply.applied') + .map( + (applied) => InstalledAutogenAppliedPackage.fromJson( + _jsonMap(applied, 'autogen.apply.applied_item'), + ), + ) + .toList(growable: false), + preservedToLocal: + _jsonList( + json['preserved_to_local'], + 'autogen.apply.preserved_to_local', + ) + .map( + (preserved) => InstalledAutogenPreservedPackage.fromJson( + _jsonMap(preserved, 'autogen.apply.preserved_item'), + ), + ) + .toList(growable: false), + ); + } + + final String targetRepoId; + final String? targetRepoPath; + final int appliedCount; + final List applied; + final List preservedToLocal; +} + +class InstalledAutogenAppliedPackage { + const InstalledAutogenAppliedPackage({ + required this.packageId, + required this.outputRelativePath, + }); + + factory InstalledAutogenAppliedPackage.fromJson(Map json) { + return InstalledAutogenAppliedPackage( + packageId: _jsonString(json['package_id'], 'autogen.apply.package_id'), + outputRelativePath: _jsonString( + json['output_relative_path'], + 'autogen.apply.output_relative_path', + ), + ); + } + + final String packageId; + final String outputRelativePath; +} + +class InstalledAutogenPreservedPackage { + const InstalledAutogenPreservedPackage({ + required this.packageId, + required this.repositoryId, + required this.relativePath, + }); + + factory InstalledAutogenPreservedPackage.fromJson(Map json) { + return InstalledAutogenPreservedPackage( + packageId: _jsonString( + json['package_id'], + 'autogen.preserved.package_id', + ), + repositoryId: _jsonString( + json['repository_id'], + 'autogen.preserved.repository_id', + ), + relativePath: _jsonString( + json['relative_path'], + 'autogen.preserved.relative_path', + ), + ); + } + + final String packageId; + final String repositoryId; + final String relativePath; +} + +class GetterError { + const GetterError({required this.code, required this.message, this.detail}); + + final String code; + final String message; + final String? detail; +} + +class GetterBridgeException implements Exception { + const GetterBridgeException(this.error, {this.exitCode}); + + final GetterError error; + final int? exitCode; + + @override + String toString() { + final detail = error.detail == null ? '' : ': ${error.detail}'; + final exit = exitCode == null ? '' : ' (exit $exitCode)'; + return 'GetterBridgeException$exit: ${error.code}: ${error.message}$detail'; + } +} + +Map _jsonMap(Object? value, String name) { + if (value is Map) return value; + if (value is Map) return value.cast(); + throw FormatException('$name should be a JSON object'); +} + +Map? _jsonMapOrNull(Object? value, String name) { + if (value == null) return null; + return _jsonMap(value, name); +} + +List _jsonList(Object? value, String name) { + if (value is List) return value; + if (value is List) return value.cast(); + throw FormatException('$name should be a JSON array'); +} + +String _jsonString(Object? value, String name) { + if (value is String) return value; + throw FormatException('$name should be a string'); +} + +String? _jsonOptionalString(Object? value, String name) { + if (value == null || value is String) return value as String?; + throw FormatException('$name should be a string or null'); +} + +int _jsonInt(Object? value, String name) { + if (value is int) return value; + throw FormatException('$name should be an integer'); +} + +bool _jsonBool(Object? value, String name) { + if (value is bool) return value; + throw FormatException('$name should be a boolean'); +} + +bool? _jsonOptionalBool(Object? value, String name) { + if (value == null || value is bool) return value as bool?; + throw FormatException('$name should be a boolean or null'); +} diff --git a/app_flutter/lib/legacy_migration_platform.dart b/app_flutter/lib/legacy_migration_platform.dart new file mode 100644 index 000000000..f6535289e --- /dev/null +++ b/app_flutter/lib/legacy_migration_platform.dart @@ -0,0 +1,96 @@ +// ignore_for_file: prefer_initializing_formals + +import 'package:flutter/services.dart'; + +import 'getter_adapter.dart'; + +/// Android platform boundary for preparing legacy Room databases for getter. +/// +/// This adapter is intentionally non-UI. Android code may locate, checkpoint, +/// and copy the legacy Room SQLite files, but Rust getter still owns migration +/// mapping/import semantics and Flutter owns all user-visible screens. +abstract interface class LegacyMigrationPlatform { + Future prepareLegacyRoomImport(); +} + +class LegacyRoomImportCandidate { + const LegacyRoomImportCandidate({ + required this.found, + required this.databasePath, + required this.message, + }); + + final bool found; + final String? databasePath; + final String? message; +} + +class MethodChannelLegacyMigrationPlatform implements LegacyMigrationPlatform { + // Keep the public `channel` parameter name for tests/callers. + const MethodChannelLegacyMigrationPlatform({ + MethodChannel channel = const MethodChannel( + 'net.xzos.upgradeall/legacy_migration', + ), + }) : _channel = channel; + + final MethodChannel _channel; + + @override + Future prepareLegacyRoomImport() async { + final Map? result; + try { + result = await _channel.invokeMapMethod( + 'prepareLegacyRoomImport', + ); + } on PlatformException catch (error) { + throw GetterBridgeException( + GetterError( + code: error.code, + message: error.message ?? 'Legacy migration platform adapter failed', + detail: error.details?.toString(), + ), + ); + } + if (result == null) { + throw const FormatException('legacy migration platform returned null'); + } + return _candidateFromJson(result); + } +} + +class NoopLegacyMigrationPlatform implements LegacyMigrationPlatform { + const NoopLegacyMigrationPlatform(); + + @override + Future prepareLegacyRoomImport() async { + return const LegacyRoomImportCandidate( + found: false, + databasePath: null, + message: 'Legacy migration platform adapter is not connected', + ); + } +} + +LegacyRoomImportCandidate _candidateFromJson(Map json) { + final found = json['found']; + final databasePath = json['database_path']; + final message = json['message']; + if (found is! bool) { + throw const FormatException('legacy migration found should be a boolean'); + } + if (databasePath != null && databasePath is! String) { + throw const FormatException( + 'legacy migration database_path should be a string or null', + ); + } + if (message != null && message is! String) { + throw const FormatException( + 'legacy migration message should be a string or null', + ); + } + return LegacyRoomImportCandidate( + found: found, + databasePath: databasePath as String?, + message: message as String?, + ); +} diff --git a/app_flutter/lib/main.dart b/app_flutter/lib/main.dart new file mode 100644 index 000000000..794430e70 --- /dev/null +++ b/app_flutter/lib/main.dart @@ -0,0 +1,1067 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'getter_adapter.dart'; +import 'legacy_migration_platform.dart'; +import 'native_getter_adapter.dart'; + +void main() { + runApp( + const UpgradeAllApp( + getter: MethodChannelGetterAdapter(), + legacyMigrationPlatform: MethodChannelLegacyMigrationPlatform(), + ), + ); +} + +@visibleForTesting +class AppKeys { + static const homeRoute = ValueKey('route.home'); + static const appsRoute = ValueKey('route.apps'); + static const appDetailRoute = ValueKey('route.app_detail'); + static const repositoriesRoute = ValueKey('route.repositories'); + static const downloadsRoute = ValueKey('route.downloads'); + static const logsRoute = ValueKey('route.logs'); + static const settingsRoute = ValueKey('route.settings'); + static const migrationRoute = ValueKey('route.migration'); + static const installedAutogenRoute = ValueKey( + 'route.installed_autogen', + ); + + static const openApps = ValueKey('action.open_apps'); + static const openRepositories = ValueKey('action.open_repositories'); + static const openDownloads = ValueKey('action.open_downloads'); + static const openLogs = ValueKey('action.open_logs'); + static const openSettings = ValueKey('action.open_settings'); + static const openMigration = ValueKey('action.open_migration'); + static const openInstalledAutogen = ValueKey( + 'action.open_installed_autogen', + ); + static const openFirstApp = ValueKey('action.open_first_app'); + static const startLegacyMigration = ValueKey( + 'action.start_legacy_migration', + ); + static const previewInstalledAutogen = ValueKey( + 'action.preview_installed_autogen', + ); + static const applyInstalledAutogen = ValueKey( + 'action.apply_installed_autogen', + ); + static const updateCheckStatus = ValueKey( + 'state.update_check_status', + ); + static const updateCheckError = ValueKey('state.update_check_error'); + + static const updateSummary = ValueKey('state.update_summary'); + static const getterStatus = ValueKey('state.getter_status'); + static const appsList = ValueKey('state.apps_list'); + static const repositoriesList = ValueKey('state.repositories_list'); + static const downloadsList = ValueKey('state.downloads_list'); + static const downloadsEmpty = ValueKey('state.downloads_empty'); + static const taskEventsList = ValueKey('state.task_events_list'); + static const logsEmpty = ValueKey('state.logs_empty'); + static const settingsShell = ValueKey('state.settings_shell'); + static const migrationReady = ValueKey('state.migration_ready'); + static const migrationStatus = ValueKey('state.migration_status'); + static const migrationBridgeUnavailable = ValueKey( + 'state.migration_bridge_unavailable', + ); + static const migrationImported = ValueKey('state.migration_imported'); + static const migrationError = ValueKey('state.migration_error'); + static const migrationReportsList = ValueKey( + 'state.migration_reports_list', + ); + static const installedAutogenReady = ValueKey( + 'state.installed_autogen_ready', + ); + static const installedAutogenBridgeUnavailable = ValueKey( + 'state.installed_autogen_bridge_unavailable', + ); + static const installedAutogenPreview = ValueKey( + 'state.installed_autogen_preview', + ); + static const installedAutogenCandidatesList = ValueKey( + 'state.installed_autogen_candidates_list', + ); + static const installedAutogenSkipsList = ValueKey( + 'state.installed_autogen_skips_list', + ); + static const installedAutogenDiagnosticsList = ValueKey( + 'state.installed_autogen_diagnostics_list', + ); + static const installedAutogenScanStats = ValueKey( + 'state.installed_autogen_scan_stats', + ); + static const installedAutogenApplied = ValueKey( + 'state.installed_autogen_applied', + ); + static const installedAutogenError = ValueKey( + 'state.installed_autogen_error', + ); + + static ValueKey checkPackageUpdate(String packageId) => + ValueKey('action.check_update.$packageId'); + static ValueKey appRow(String packageId) => + ValueKey('state.app.$packageId'); + static ValueKey repoRow(String repositoryId) => + ValueKey('state.repository.$repositoryId'); + static ValueKey downloadTaskRow(String taskId) => + ValueKey('state.download_task.$taskId'); + static ValueKey taskEventRow(int cursor) => + ValueKey('state.task_event.$cursor'); + static ValueKey autogenCandidateRow(String packageId) => + ValueKey('state.autogen_candidate.$packageId'); + static ValueKey autogenSkipRow(String packageId) => + ValueKey('state.autogen_skip.$packageId'); + static ValueKey autogenDiagnosticRow(int index) => + ValueKey('state.autogen_diagnostic.$index'); + static ValueKey autogenAppliedRow(String packageId) => + ValueKey('state.autogen_applied.$packageId'); +} + +class UpgradeAllApp extends StatelessWidget { + const UpgradeAllApp({ + super.key, + this.getter = const FakeGetterAdapter(), + this.legacyMigrationPlatform = const NoopLegacyMigrationPlatform(), + }); + + final GetterAdapter getter; + final LegacyMigrationPlatform legacyMigrationPlatform; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'UpgradeAll', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + routes: { + '/': (context) => HomePage(getter: getter), + '/apps': (context) => AppsPage(getter: getter), + '/repositories': (context) => RepositoriesPage(getter: getter), + '/downloads': (context) => DownloadsPage(getter: getter), + '/logs': (context) => const LogsPage(), + '/settings': (context) => const SettingsPage(), + '/migration': (context) => MigrationPage( + getter: getter, + legacyMigrationPlatform: legacyMigrationPlatform, + ), + '/autogen': (context) => InstalledAutogenPage(getter: getter), + }, + onGenerateRoute: (settings) { + if (settings.name == '/apps/detail') { + final app = settings.arguments! as AppSummary; + return MaterialPageRoute( + builder: (context) => AppDetailPage(app: app, getter: getter), + settings: settings, + ); + } + return null; + }, + ); + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key, required this.getter}); + + final GetterAdapter getter; + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + late final Future _snapshot = widget.getter.loadSnapshot(); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: AppKeys.homeRoute, + appBar: AppBar(title: const Text('UpgradeAll')), + body: FutureBuilder( + future: _snapshot, + builder: (context, snapshot) { + final data = snapshot.data; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + key: AppKeys.updateSummary, + child: ListTile( + title: const Text('Updates'), + subtitle: Text('${data?.updateCount ?? 0} updates available'), + ), + ), + Card( + key: AppKeys.getterStatus, + child: ListTile( + title: const Text('Getter core'), + subtitle: Text( + snapshot.hasError + ? 'Getter snapshot unavailable' + : data?.status ?? 'Loading getter snapshot...', + ), + ), + ), + const SizedBox(height: 16), + const _RouteButton( + key: AppKeys.openApps, + icon: Icons.apps, + label: 'Apps', + routeName: '/apps', + ), + const _RouteButton( + key: AppKeys.openRepositories, + icon: Icons.source, + label: 'Repositories', + routeName: '/repositories', + ), + const _RouteButton( + key: AppKeys.openDownloads, + icon: Icons.download, + label: 'Downloads', + routeName: '/downloads', + ), + const _RouteButton( + key: AppKeys.openLogs, + icon: Icons.receipt_long, + label: 'Logs', + routeName: '/logs', + ), + const _RouteButton( + key: AppKeys.openSettings, + icon: Icons.settings, + label: 'Settings', + routeName: '/settings', + ), + const _RouteButton( + key: AppKeys.openMigration, + icon: Icons.move_down, + label: 'Legacy migration', + routeName: '/migration', + ), + const _RouteButton( + key: AppKeys.openInstalledAutogen, + icon: Icons.auto_fix_high, + label: 'Installed autogen', + routeName: '/autogen', + ), + ], + ); + }, + ), + ); + } +} + +class AppsPage extends StatefulWidget { + const AppsPage({super.key, required this.getter}); + + final GetterAdapter getter; + + @override + State createState() => _AppsPageState(); +} + +class _AppsPageState extends State { + late final Future _snapshot = widget.getter.loadSnapshot(); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: AppKeys.appsRoute, + appBar: AppBar(title: const Text('Apps')), + body: FutureBuilder( + future: _snapshot, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: Text('Loading getter apps...')); + } + if (snapshot.hasError) { + return const Center(child: Text('Getter apps unavailable')); + } + final apps = snapshot.data?.apps ?? const []; + return ListView.builder( + key: AppKeys.appsList, + itemCount: apps.length, + itemBuilder: (context, index) { + final app = apps[index]; + return ListTile( + key: AppKeys.appRow(app.id), + title: Text(app.name), + subtitle: Text('${app.id} • ${app.installedVersion}'), + trailing: app.hasFreeNetworkWarning + ? const Chip( + label: Text('Network'), + backgroundColor: Colors.amber, + ) + : null, + onTap: () { + Navigator.of( + context, + ).pushNamed('/apps/detail', arguments: app); + }, + ); + }, + ); + }, + ), + ); + } +} + +class AppDetailPage extends StatefulWidget { + const AppDetailPage({super.key, required this.app, required this.getter}); + + final AppSummary app; + final GetterAdapter getter; + + @override + State createState() => _AppDetailPageState(); +} + +class _AppDetailPageState extends State { + bool _checkingUpdate = false; + String? _status; + String? _error; + + Future _checkForUpdate() async { + if (_checkingUpdate) return; + setState(() { + _checkingUpdate = true; + _status = 'Checking for updates...'; + _error = null; + }); + + try { + final result = await widget.getter.checkPackageForUpdate( + widget.app.id, + installedVersion: _knownVersion(widget.app.installedVersion), + ); + final action = result.action; + if (action == null) { + if (!mounted) return; + setState(() { + _status = 'No update task available: ${result.update.status}'; + }); + return; + } + + final task = await widget.getter.submitRuntimeAction(action.actionId); + if (!mounted) return; + setState(() { + _status = 'Submitted runtime task ${task.taskId}'; + }); + await Navigator.of(context).pushNamed('/downloads'); + } catch (error) { + if (!mounted) return; + setState(() { + _status = null; + _error = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _checkingUpdate = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final app = widget.app; + return Scaffold( + key: AppKeys.appDetailRoute, + appBar: AppBar(title: Text(app.name)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(app.id, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + Text('Installed: ${app.installedVersion}'), + Text('Latest: ${app.latestVersion}'), + const SizedBox(height: 16), + FilledButton.icon( + key: AppKeys.checkPackageUpdate(app.id), + onPressed: _checkingUpdate ? null : _checkForUpdate, + icon: const Icon(Icons.system_update_alt), + label: Text( + _checkingUpdate ? 'Checking update...' : 'Check update', + ), + ), + if (_status != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(key: AppKeys.updateCheckStatus, _status!), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(key: AppKeys.updateCheckError, _error!), + ), + if (app.hasFreeNetworkWarning) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Chip( + label: Text('Network access required'), + backgroundColor: Colors.amber, + ), + ), + ], + ), + ); + } +} + +String? _knownVersion(String version) { + final normalized = version.trim(); + return normalized.isEmpty || normalized == 'unknown' ? null : normalized; +} + +class RepositoriesPage extends StatefulWidget { + const RepositoriesPage({super.key, required this.getter}); + + final GetterAdapter getter; + + @override + State createState() => _RepositoriesPageState(); +} + +class _RepositoriesPageState extends State { + late final Future _snapshot = widget.getter.loadSnapshot(); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: AppKeys.repositoriesRoute, + appBar: AppBar(title: const Text('Repositories')), + body: FutureBuilder( + future: _snapshot, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: Text('Loading getter repositories...')); + } + if (snapshot.hasError) { + return const Center(child: Text('Getter repositories unavailable')); + } + final repositories = + snapshot.data?.repositories ?? const []; + return ListView.builder( + key: AppKeys.repositoriesList, + itemCount: repositories.length, + itemBuilder: (context, index) { + final repository = repositories[index]; + return ListTile( + key: AppKeys.repoRow(repository.id), + title: Text(repository.id), + subtitle: Text('Priority ${repository.priority}'), + ); + }, + ); + }, + ), + ); + } +} + +class DownloadsPage extends StatefulWidget { + const DownloadsPage({super.key, required this.getter}); + + final GetterAdapter getter; + + @override + State createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends State { + late Future> _tasks = widget.getter + .listRuntimeTasks(); + StreamSubscription? _notificationSubscription; + + @override + void initState() { + super.initState(); + _notificationSubscription = widget.getter + .runtimeNotificationEnvelopes() + .listen((notification) { + if (notification.kind == 'task_changed') { + _reloadTasks(); + } + }, onError: (_) {}); + } + + @override + void dispose() { + _notificationSubscription?.cancel(); + super.dispose(); + } + + void _reloadTasks() { + if (!mounted) return; + setState(() { + _tasks = widget.getter.listRuntimeTasks(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: AppKeys.downloadsRoute, + appBar: AppBar(title: const Text('Downloads')), + body: FutureBuilder>( + future: _tasks, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return const Center( + child: Text( + key: AppKeys.downloadsEmpty, + 'Runtime tasks unavailable', + ), + ); + } + final tasks = snapshot.data ?? const []; + if (tasks.isEmpty) { + return const Center( + child: Text(key: AppKeys.downloadsEmpty, 'No runtime tasks yet'), + ); + } + return ListView.builder( + key: AppKeys.downloadsList, + padding: const EdgeInsets.all(16), + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return Card( + child: ListTile( + key: AppKeys.downloadTaskRow(task.taskId), + title: Text(task.packageId), + subtitle: Text('${task.status} • ${task.phase.category}'), + trailing: _TaskCapabilitiesChips( + capabilities: task.capabilities, + ), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _TaskCapabilitiesChips extends StatelessWidget { + const _TaskCapabilitiesChips({required this.capabilities}); + + final RuntimeTaskCapabilities capabilities; + + @override + Widget build(BuildContext context) { + final labels = [ + if (capabilities.cancel) 'Cancel', + if (capabilities.pause) 'Pause', + if (capabilities.resume) 'Resume', + if (capabilities.retry) 'Retry', + ]; + if (labels.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 4, + children: labels.map((label) => Chip(label: Text(label))).toList(), + ); + } +} + +class InstalledAutogenPage extends StatefulWidget { + const InstalledAutogenPage({super.key, required this.getter}); + + final GetterAdapter getter; + + @override + State createState() => _InstalledAutogenPageState(); +} + +class _InstalledAutogenPageState extends State { + InstalledAutogenPreview? _preview; + InstalledAutogenApplyResult? _applyResult; + GetterError? _error; + bool _running = false; + + Future _previewInstalledAutogen() async { + setState(() { + _running = true; + _error = null; + _applyResult = null; + }); + try { + final preview = await widget.getter.previewInstalledAutogen(); + if (!mounted) return; + setState(() { + _preview = preview; + _running = false; + }); + } on GetterBridgeException catch (error) { + if (!mounted) return; + setState(() { + _error = error.error; + _running = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _error = GetterError( + code: 'bridge.installed_autogen_error', + message: 'Installed autogen bridge failed', + detail: error.toString(), + ); + _running = false; + }); + } + } + + Future _applyInstalledAutogen() async { + final preview = _preview; + if (preview == null) return; + setState(() { + _running = true; + _error = null; + }); + try { + final result = await widget.getter.applyInstalledAutogen( + preview, + acceptedPackageIds: preview.candidates + .map((candidate) => candidate.packageId) + .toList(growable: false), + ); + if (!mounted) return; + setState(() { + _applyResult = result; + _running = false; + }); + } on GetterBridgeException catch (error) { + if (!mounted) return; + setState(() { + _error = error.error; + _running = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _error = GetterError( + code: 'bridge.installed_autogen_error', + message: 'Installed autogen bridge failed', + detail: error.toString(), + ); + _running = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final preview = _preview; + final applyResult = _applyResult; + final canUseBridge = widget.getter.supportsInstalledAutogen; + return Scaffold( + key: AppKeys.installedAutogenRoute, + appBar: AppBar(title: const Text('Installed autogen')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ElevatedButton.icon( + key: AppKeys.previewInstalledAutogen, + onPressed: _running || !canUseBridge + ? null + : _previewInstalledAutogen, + icon: const Icon(Icons.manage_search), + label: Text(_running ? 'Working…' : 'Preview installed autogen'), + ), + if (!canUseBridge) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text( + key: AppKeys.installedAutogenBridgeUnavailable, + 'Getter installed-autogen bridge is not connected', + ), + ), + if (preview == null && _error == null) + const Padding( + padding: EdgeInsets.only(top: 16), + child: Text( + key: AppKeys.installedAutogenReady, + 'Ready to preview installed app fallback packages', + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + key: AppKeys.installedAutogenError, + '${_error!.code}: ${_error!.message}', + ), + ), + if (preview != null) ...[ + const SizedBox(height: 16), + Text( + key: AppKeys.installedAutogenPreview, + '${preview.summary.candidateCount} candidates, ${preview.summary.skippedCount} skipped', + ), + if (preview.scanStats != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + key: AppKeys.installedAutogenScanStats, + 'Seen ${preview.scanStats!.totalSeen}, returned ${preview.scanStats!.returned}, filtered system ${preview.scanStats!.filteredSystem}, filtered self ${preview.scanStats!.filteredSelf}', + ), + ), + const SizedBox(height: 16), + Text('Candidates', style: Theme.of(context).textTheme.titleMedium), + ListView.builder( + key: AppKeys.installedAutogenCandidatesList, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: preview.candidates.length, + itemBuilder: (context, index) { + final candidate = preview.candidates[index]; + return ListTile( + key: AppKeys.autogenCandidateRow(candidate.packageId), + title: Text(candidate.displayName), + subtitle: Text( + '${candidate.packageId} • ${candidate.outputRelativePath}', + ), + ); + }, + ), + if (preview.skipped.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('Skipped', style: Theme.of(context).textTheme.titleMedium), + ListView.builder( + key: AppKeys.installedAutogenSkipsList, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: preview.skipped.length, + itemBuilder: (context, index) { + final skipped = preview.skipped[index]; + return ListTile( + key: AppKeys.autogenSkipRow(skipped.packageId), + title: Text(skipped.packageId), + subtitle: Text( + '${skipped.reason}${skipped.coveringRepoId == null ? '' : ' • ${skipped.coveringRepoId}'}', + ), + ); + }, + ), + ], + if (preview.diagnostics.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Diagnostics', + style: Theme.of(context).textTheme.titleMedium, + ), + ListView.builder( + key: AppKeys.installedAutogenDiagnosticsList, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: preview.diagnostics.length, + itemBuilder: (context, index) { + final diagnostic = preview.diagnostics[index]; + return ListTile( + key: AppKeys.autogenDiagnosticRow(index), + title: Text(diagnostic.code), + subtitle: Text(diagnostic.message), + ); + }, + ), + ], + const SizedBox(height: 16), + ElevatedButton.icon( + key: AppKeys.applyInstalledAutogen, + onPressed: _running || preview.candidates.isEmpty + ? null + : _applyInstalledAutogen, + icon: const Icon(Icons.check), + label: const Text('Apply all candidates'), + ), + ], + if (applyResult != null) ...[ + const SizedBox(height: 16), + Text( + key: AppKeys.installedAutogenApplied, + 'Applied ${applyResult.appliedCount} packages', + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: applyResult.applied.length, + itemBuilder: (context, index) { + final applied = applyResult.applied[index]; + return ListTile( + key: AppKeys.autogenAppliedRow(applied.packageId), + title: Text(applied.packageId), + subtitle: Text(applied.outputRelativePath), + ); + }, + ), + ], + ], + ), + ); + } +} + +class LogsPage extends StatelessWidget { + const LogsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _PlaceholderPage( + key: AppKeys.logsRoute, + title: 'Logs', + stateKey: AppKeys.logsEmpty, + message: 'No getter events yet', + ); + } +} + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _PlaceholderPage( + key: AppKeys.settingsRoute, + title: 'Settings', + stateKey: AppKeys.settingsShell, + message: 'Settings shell ready', + ); + } +} + +class MigrationPage extends StatefulWidget { + const MigrationPage({ + super.key, + required this.getter, + required this.legacyMigrationPlatform, + }); + + final GetterAdapter getter; + final LegacyMigrationPlatform legacyMigrationPlatform; + + @override + State createState() => _MigrationPageState(); +} + +class _MigrationPageState extends State { + List _reports = const []; + LegacyMigrationImportResult? _importResult; + String? _status; + GetterError? _error; + bool _running = false; + + @override + void initState() { + super.initState(); + _loadMigrationReports(); + } + + Future _loadMigrationReports() async { + try { + final reports = await widget.getter.readMigrationReports(); + if (!mounted) return; + setState(() { + _reports = reports; + }); + } on GetterBridgeException { + // Reports are best-effort on page open. The explicit migration action + // surfaces bridge errors to the user. + } + } + + Future _startMigration() async { + setState(() { + _running = true; + _status = 'Preparing legacy Room database'; + _error = null; + }); + + try { + final candidate = await widget.legacyMigrationPlatform + .prepareLegacyRoomImport(); + if (!mounted) return; + if (!candidate.found || candidate.databasePath == null) { + setState(() { + _status = candidate.message ?? 'No legacy Room database found'; + _running = false; + }); + return; + } + + final importResult = await widget.getter.importLegacyRoomDatabase( + candidate.databasePath!, + ); + final reports = await widget.getter.readMigrationReports(); + if (!mounted) return; + setState(() { + _importResult = importResult; + _reports = reports; + _status = importResult.alreadyImported + ? 'Legacy migration was already completed' + : 'Legacy migration imported ${importResult.importedRecords} records'; + _running = false; + }); + } on GetterBridgeException catch (error) { + var reports = _reports; + try { + reports = await widget.getter.readMigrationReports(); + } on GetterBridgeException { + // Keep the reports already on screen if the bridge cannot list them. + } + if (!mounted) return; + setState(() { + _error = error.error; + _status = error.error.message; + _reports = reports; + _running = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _error = GetterError( + code: 'platform.legacy_migration_error', + message: 'Legacy migration platform adapter failed', + detail: error.toString(), + ); + _status = 'Legacy migration platform adapter failed'; + _running = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final canImportLegacyRoom = widget.getter.supportsLegacyRoomImport; + return Scaffold( + key: AppKeys.migrationRoute, + appBar: AppBar(title: const Text('Legacy migration')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ElevatedButton.icon( + key: AppKeys.startLegacyMigration, + onPressed: _running || !canImportLegacyRoom + ? null + : _startMigration, + icon: const Icon(Icons.move_down), + label: Text(_running ? 'Migrating…' : 'Start legacy migration'), + ), + if (!canImportLegacyRoom) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text( + key: AppKeys.migrationBridgeUnavailable, + 'Getter migration bridge is not connected', + ), + ), + const SizedBox(height: 16), + if (_status == null && _reports.isEmpty) + const Text( + key: AppKeys.migrationReady, + 'Ready to show migration reports', + ), + if (_status != null) Text(key: AppKeys.migrationStatus, _status!), + if (_importResult != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + key: AppKeys.migrationImported, + '${_importResult!.trackedPackages.length} tracked packages after import', + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + key: AppKeys.migrationError, + '${_error!.code}: ${_error!.message}', + ), + ), + if (_reports.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('Reports', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ListView.builder( + key: AppKeys.migrationReportsList, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _reports.length, + itemBuilder: (context, index) { + final report = _reports[index]; + return ListTile( + title: Text(report.code), + subtitle: Text( + '${report.message} • imported ${report.importedRecords}', + ), + trailing: report.ok + ? const Icon(Icons.check_circle, color: Colors.green) + : const Icon(Icons.error, color: Colors.red), + ); + }, + ), + ], + ], + ), + ); + } +} + +class _RouteButton extends StatelessWidget { + const _RouteButton({ + super.key, + required this.icon, + required this.label, + required this.routeName, + }); + + final IconData icon; + final String label; + final String routeName; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: FilledButton.icon( + onPressed: () => Navigator.of(context).pushNamed(routeName), + icon: Icon(icon), + label: Text(label), + ), + ); + } +} + +class _PlaceholderPage extends StatelessWidget { + const _PlaceholderPage({ + super.key, + required this.title, + required this.stateKey, + required this.message, + }); + + final String title; + final Key stateKey; + final String message; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: Center(child: Text(key: stateKey, message)), + ); + } +} diff --git a/app_flutter/lib/native_getter_adapter.dart b/app_flutter/lib/native_getter_adapter.dart new file mode 100644 index 000000000..bbaa21e54 --- /dev/null +++ b/app_flutter/lib/native_getter_adapter.dart @@ -0,0 +1,403 @@ +// ignore_for_file: prefer_initializing_formals + +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'getter_adapter.dart'; + +/// Android production getter bridge. +/// +/// The bridge returns getter-owned JSON envelopes; Dart parses and renders them +/// but does not scan PackageManager, resolve repositories, evaluate Lua, or make +/// autogen/update/runtime decisions. +class MethodChannelGetterAdapter extends FakeGetterAdapter { + // Keep public parameter names stable for tests and injected bridges. + const MethodChannelGetterAdapter({ + MethodChannel channel = const MethodChannel( + 'net.xzos.upgradeall/getter_bridge', + ), + EventChannel runtimeNotificationChannel = const EventChannel( + 'net.xzos.upgradeall/runtime_notifications', + ), + }) : _channel = channel, + _runtimeNotificationChannel = runtimeNotificationChannel; + + final MethodChannel _channel; + final EventChannel _runtimeNotificationChannel; + + @override + bool get supportsLegacyRoomImport => true; + + @override + bool get supportsInstalledAutogen => true; + + @override + void initialize() { + // The installed-autogen bridge initializes lazily when preview is called. + } + + @override + Future> readMigrationReports() async { + final data = await _invokeGetterData( + 'legacyReportList', + const {}, + ); + final reports = _asList(data['reports'], 'legacy reports'); + return reports + .map( + (report) => + MigrationReportSummary.fromJson(_asMap(report, 'legacy report')), + ) + .toList(growable: false); + } + + @override + Future importLegacyRoomDatabase( + String databasePath, + ) async { + final data = await _invokeGetterData( + 'importLegacyRoomDatabase', + {'database_path': databasePath}, + ); + return LegacyMigrationImportResult.fromJson(data); + } + + @override + Future previewInstalledAutogen({ + InstalledAutogenScanOptions options = const InstalledAutogenScanOptions(), + }) async { + final data = await _invokeGetterData( + 'previewInstalledAutogen', + {'scan_options': options.toJson()}, + ); + return InstalledAutogenPreview.fromJson(data); + } + + @override + Future applyInstalledAutogen( + InstalledAutogenPreview preview, { + List? acceptedPackageIds, + }) async { + final data = await _invokeGetterData( + 'applyInstalledAutogen', + { + 'preview_json': jsonEncode(preview.rawJson), + 'acceptance': acceptedPackageIds == null + ? const {'mode': 'all'} + : { + 'mode': 'packages', + 'package_ids': acceptedPackageIds, + }, + }, + ); + return InstalledAutogenApplyResult.fromJson(data); + } + + Future> invokeReadOperation( + String operation, { + Map payload = const {}, + }) { + return _invokeGetterData('readOperation', { + 'operation': operation, + 'payload': payload, + }); + } + + /// Invoke a getter runtime operation through the native bridge. + /// + /// This is an internal/debug bridge primitive for ADR-0011 wiring. Product UI + /// should use typed getter operations and getter-issued `action_id`s rather + /// than assembling runtime action plans in Dart. + Stream> runtimeNotifications() { + return _runtimeNotificationChannel.receiveBroadcastStream().map((event) { + if (event is String) { + return _asMap(jsonDecode(event), 'runtime notification'); + } + return _asMap(event, 'runtime notification'); + }); + } + + @override + Stream runtimeNotificationEnvelopes() { + return runtimeNotifications().map(RuntimeNotificationEnvelope.fromJson); + } + + Future> invokeRuntimeOperation( + String operation, { + Map payload = const {}, + }) { + return _invokeGetterData('runtimeOperation', { + 'operation': operation, + 'payload': payload, + }); + } + + @override + Future loadSnapshot() async { + final repositoriesData = await invokeReadOperation('repository_list'); + final trackedData = await invokeReadOperation('tracked_package_list'); + final repositories = _asList( + repositoriesData['repositories'], + 'repositories', + ).map(_repositoryFromJson).toList(growable: false); + final trackedPackages = _asList(trackedData['packages'], 'tracked packages') + .map( + (tracked) => TrackedPackageSummary.fromJson( + _asMap(tracked, 'tracked package'), + ), + ) + .toList(growable: false); + final apps = []; + for (final tracked in trackedPackages) { + try { + final package = await _evaluatePackageFromGetter( + tracked.id, + repositoryId: tracked.repositoryId, + ); + apps.add( + AppSummary( + id: tracked.id, + name: package.name, + installedVersion: 'unknown', + latestVersion: 'unknown', + hasFreeNetworkWarning: package.hasFreeNetworkWarning, + ), + ); + } catch (_) { + apps.add( + AppSummary( + id: tracked.id, + name: tracked.id, + installedVersion: 'unknown', + latestVersion: 'unknown', + hasFreeNetworkWarning: false, + ), + ); + } + } + return GetterSnapshot( + status: 'Getter native bridge ready', + updateCount: 0, + apps: apps, + repositories: repositories, + ); + } + + Future _evaluatePackageFromGetter( + String packageId, { + String? repositoryId, + }) async { + final data = await invokeReadOperation( + 'package_eval', + payload: { + 'package_id': packageId, + 'repository_id': ?repositoryId, + }, + ); + return _packageEvaluationFromJson(_asMap(data['package'], 'package')); + } + + @override + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }) async { + final payload = { + 'package_id': packageId, + 'repository_id': ?repositoryId, + 'installed_version': ?installedVersion, + 'pin_version': ?pinVersion, + }; + final data = await invokeRuntimeOperation( + 'update_check_package_issue_action', + payload: payload, + ); + return RuntimeUpdateCheckResult.fromJson(data); + } + + @override + Future submitRuntimeAction(String actionId) { + return _runtimeTaskOperation('task_submit', { + 'action_id': actionId, + }); + } + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) async { + final data = await invokeRuntimeOperation( + 'task_list', + payload: {'active': active, 'package_id': ?packageId}, + ); + return _runtimeTasksFromData(data); + } + + @override + Future getRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_get', _taskIdPayload(taskId)); + } + + @override + Future startRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_start', _taskIdPayload(taskId)); + } + + @override + Future pauseRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_pause', _taskIdPayload(taskId)); + } + + @override + Future resumeRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_resume', _taskIdPayload(taskId)); + } + + @override + Future cancelRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_cancel', _taskIdPayload(taskId)); + } + + @override + Future retryRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_retry', _taskIdPayload(taskId)); + } + + @override + Future removeRuntimeTask(String taskId) { + return _runtimeTaskOperation('task_remove', _taskIdPayload(taskId)); + } + + @override + Future sendRuntimeUserResult( + String taskId, + RuntimeUserResult result, { + String? reason, + }) { + return _runtimeTaskOperation('task_user_result', { + 'task_id': taskId, + 'result': result.wireName, + 'reason': ?reason, + }); + } + + @override + Future> cleanRuntimeTasks({ + RuntimeTaskCleanMode mode = RuntimeTaskCleanMode.defaultMode, + }) async { + final data = await invokeRuntimeOperation( + 'task_clean', + payload: {'mode': mode.wireName}, + ); + return _runtimeTasksFromData(data); + } + + Future _runtimeTaskOperation( + String operation, + Map payload, + ) async { + final data = await invokeRuntimeOperation(operation, payload: payload); + return RuntimeTaskSnapshot.fromJson(data); + } + + List _runtimeTasksFromData(Map data) { + return _asList(data['tasks'], 'runtime tasks') + .map((task) => RuntimeTaskSnapshot.fromJson(_asMap(task, 'task'))) + .toList(growable: false); + } + + Map _taskIdPayload(String taskId) { + return {'task_id': taskId}; + } + + Future> _invokeGetterData( + String method, + Map arguments, + ) async { + try { + final response = await _channel.invokeMethod(method, arguments); + if (response == null || response.isEmpty) { + throw const GetterBridgeException( + GetterError( + code: 'bridge.empty_response', + message: 'Getter native bridge returned an empty response', + ), + ); + } + final envelope = _asMap(jsonDecode(response), 'getter bridge response'); + if (envelope['ok'] != true) { + throw GetterBridgeException(_errorFromEnvelope(envelope)); + } + return _asMap(envelope['data'], 'getter bridge data'); + } on PlatformException catch (error) { + throw GetterBridgeException( + GetterError( + code: error.code, + message: error.message ?? 'Getter native bridge call failed', + detail: error.details?.toString(), + ), + ); + } + } +} + +RepositorySummary _repositoryFromJson(Object? value) { + final json = _asMap(value, 'repository'); + return RepositorySummary( + id: _asString(json['id'], 'repository.id'), + priority: _asInt(json['priority'], 'repository.priority'), + ); +} + +PackageEvaluation _packageEvaluationFromJson(Map json) { + final permissions = _asMap(json['permissions'], 'package.permissions'); + return PackageEvaluation( + id: _asString(json['id'], 'package.id'), + repositoryId: _asString(json['repository'], 'package.repository'), + name: _asString(json['name'], 'package.name'), + hasFreeNetworkWarning: _asBool( + permissions['free_network'], + 'package.permissions.free_network', + ), + ); +} + +GetterError _errorFromEnvelope(Map envelope) { + final error = _asMap(envelope['error'], 'getter bridge error'); + return GetterError( + code: _asString(error['code'], 'getter bridge error.code'), + message: _asString(error['message'], 'getter bridge error.message'), + detail: error['detail']?.toString(), + ); +} + +Map _asMap(Object? value, String name) { + if (value is Map) return value; + if (value is Map) return value.cast(); + throw FormatException('$name should be a JSON object'); +} + +List _asList(Object? value, String name) { + if (value is List) return value; + if (value is List) return value.cast(); + throw FormatException('$name should be a JSON array'); +} + +String _asString(Object? value, String name) { + if (value is String) return value; + throw FormatException('$name should be a string'); +} + +int _asInt(Object? value, String name) { + if (value is int) return value; + throw FormatException('$name should be an integer'); +} + +bool _asBool(Object? value, String name) { + if (value is bool) return value; + throw FormatException('$name should be a boolean'); +} diff --git a/app_flutter/linux/.gitignore b/app_flutter/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/app_flutter/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app_flutter/linux/CMakeLists.txt b/app_flutter/linux/CMakeLists.txt new file mode 100644 index 000000000..7492bd8ae --- /dev/null +++ b/app_flutter/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "upgradeall") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "net.xzos.upgradeall") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app_flutter/linux/flutter/CMakeLists.txt b/app_flutter/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/app_flutter/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app_flutter/linux/flutter/generated_plugin_registrant.cc b/app_flutter/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..e71a16d23 --- /dev/null +++ b/app_flutter/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/app_flutter/linux/flutter/generated_plugin_registrant.h b/app_flutter/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/app_flutter/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app_flutter/linux/flutter/generated_plugins.cmake b/app_flutter/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2e1de87a7 --- /dev/null +++ b/app_flutter/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app_flutter/linux/main.cc b/app_flutter/linux/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/app_flutter/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app_flutter/linux/my_application.cc b/app_flutter/linux/my_application.cc new file mode 100644 index 000000000..313ff25ed --- /dev/null +++ b/app_flutter/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "upgradeall"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "upgradeall"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/app_flutter/linux/my_application.h b/app_flutter/linux/my_application.h new file mode 100644 index 000000000..72271d5e4 --- /dev/null +++ b/app_flutter/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app_flutter/pubspec.lock b/app_flutter/pubspec.lock new file mode 100644 index 000000000..1f6541fcf --- /dev/null +++ b/app_flutter/pubspec.lock @@ -0,0 +1,268 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.4" diff --git a/app_flutter/pubspec.yaml b/app_flutter/pubspec.yaml new file mode 100644 index 000000000..710ac251b --- /dev/null +++ b/app_flutter/pubspec.yaml @@ -0,0 +1,98 @@ +name: upgradeall +description: "UpgradeAll Flutter shell backed by the Rust getter core." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 0.20.0-alpha.4+105 + +environment: + sdk: '>=3.12.2 <4.0.0' + flutter: '>=3.44.4' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db + - integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-wal + - integration_test/fixtures/legacy_room_v17_wal/app_metadata_database.db-shm + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/app_flutter/test/native_getter_adapter_test.dart b/app_flutter/test/native_getter_adapter_test.dart new file mode 100644 index 000000000..51f3e1ce6 --- /dev/null +++ b/app_flutter/test/native_getter_adapter_test.dart @@ -0,0 +1,540 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:upgradeall/getter_adapter.dart'; +import 'package:upgradeall/native_getter_adapter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test/getter_bridge'); + const eventChannel = EventChannel('test/runtime_notifications'); + const eventMethodChannel = MethodChannel('test/runtime_notifications'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(eventMethodChannel, null); + }); + + test( + 'native preview sends scan options and parses getter envelope', + () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return jsonEncode({ + 'ok': true, + 'command': 'autogen installed preview', + 'data': _previewJson(), + 'warnings': [], + }); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final preview = await adapter.previewInstalledAutogen( + options: const InstalledAutogenScanOptions( + includeSystemApps: true, + includeSelf: true, + ), + ); + + expect(captured!.method, 'previewInstalledAutogen'); + expect(captured!.arguments, { + 'scan_options': { + 'include_system_apps': true, + 'include_self': true, + }, + }); + expect(preview.summary.candidateCount, 1); + expect(preview.scanStats!.returned, 1); + expect( + preview.candidates.single.packageId, + 'android/com.example.autogen', + ); + }, + ); + + test('native apply forwards preview JSON and package acceptance', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return jsonEncode({ + 'ok': true, + 'command': 'autogen installed apply', + 'data': { + 'target_repo_id': 'local_autogen', + 'target_repo_path': '/getter/repositories/local_autogen', + 'applied_count': 1, + 'applied': [ + { + 'package_id': 'android/com.example.autogen', + 'output_relative_path': + 'packages/android/com.example.autogen.lua', + }, + ], + 'preserved_to_local': [], + }, + 'warnings': [], + }); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final preview = InstalledAutogenPreview.fromJson(_previewJson()); + final result = await adapter.applyInstalledAutogen( + preview, + acceptedPackageIds: const ['android/com.example.autogen'], + ); + + expect(captured!.method, 'applyInstalledAutogen'); + final args = (captured!.arguments as Map) + .cast(); + expect(jsonDecode(args['preview_json']! as String), preview.rawJson); + expect(args['acceptance'], { + 'mode': 'packages', + 'package_ids': ['android/com.example.autogen'], + }); + expect(result.applied.single.packageId, 'android/com.example.autogen'); + }); + + test('native legacy import and reports parse getter envelopes', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + switch (call.method) { + case 'importLegacyRoomDatabase': + return jsonEncode({ + 'ok': true, + 'command': 'legacy import-room-db', + 'data': { + 'imported_records': 1, + 'apps': [ + { + 'id': 'android/org.fdroid.fdroid', + 'enabled': true, + 'favorite': true, + 'pin_version': '1.20.0', + 'repository_id': null, + 'package_resolution': 'missing_package_definition', + }, + ], + 'warnings': [], + 'source_counts': { + 'app_rows': 1, + 'extra_app_rows': 1, + 'hub_rows': 0, + 'extra_hub_rows': 0, + }, + }, + 'warnings': [], + }); + case 'legacyReportList': + return jsonEncode({ + 'ok': true, + 'command': 'legacy report-list', + 'data': { + 'reports': [ + { + 'ok': true, + 'code': 'migration.imported', + 'message': 'Legacy Room data imported', + 'imported_records': 1, + 'tracked_records': 1, + }, + ], + }, + 'warnings': [], + }); + default: + fail('unexpected method ${call.method}'); + } + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final importResult = await adapter.importLegacyRoomDatabase( + '/tmp/legacy.db', + ); + final reports = await adapter.readMigrationReports(); + + expect(calls.map((call) => call.method), [ + 'importLegacyRoomDatabase', + 'legacyReportList', + ]); + expect(calls.first.arguments, { + 'database_path': '/tmp/legacy.db', + }); + expect(importResult.importedRecords, 1); + expect(importResult.trackedPackages.single.id, 'android/org.fdroid.fdroid'); + expect(reports.single.code, 'migration.imported'); + }); + + test( + 'native snapshot reads repositories and package data through getter', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + final args = (call.arguments as Map) + .cast(); + switch (args['operation']) { + case 'repository_list': + return jsonEncode({ + 'ok': true, + 'command': 'read operation', + 'data': { + 'repositories': [ + {'id': 'official', 'priority': 0}, + ], + }, + 'warnings': [], + }); + case 'tracked_package_list': + return jsonEncode({ + 'ok': true, + 'command': 'read operation', + 'data': { + 'packages': [ + { + 'id': 'android/org.fdroid.fdroid', + 'enabled': true, + 'favorite': false, + 'pin_version': null, + 'repository_id': 'official', + 'package_resolution': 'official_repository_package', + }, + ], + }, + 'warnings': [], + }); + case 'package_eval': + expect(args['payload'], { + 'package_id': 'android/org.fdroid.fdroid', + 'repository_id': 'official', + }); + return jsonEncode({ + 'ok': true, + 'command': 'read operation', + 'data': { + 'package': { + 'id': 'android/org.fdroid.fdroid', + 'name': 'F-Droid', + 'repository': 'official', + 'permissions': {'free_network': true}, + }, + }, + 'warnings': [], + }); + default: + fail('unexpected read operation ${args['operation']}'); + } + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final snapshot = await adapter.loadSnapshot(); + + expect(calls.map((call) => call.method), [ + 'readOperation', + 'readOperation', + 'readOperation', + ]); + expect(snapshot.status, 'Getter native bridge ready'); + expect(snapshot.repositories.single.id, 'official'); + expect(snapshot.apps.single.id, 'android/org.fdroid.fdroid'); + expect(snapshot.apps.single.name, 'F-Droid'); + expect(snapshot.apps.single.hasFreeNetworkWarning, isTrue); + }, + ); + + test('runtime notification stream decodes pushed JSON events', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(eventMethodChannel, (call) async { + if (call.method == 'listen') { + await TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .handlePlatformMessage( + 'test/runtime_notifications', + const StandardMethodCodec().encodeSuccessEnvelope( + jsonEncode({ + 'kind': 'task_changed', + 'task': { + 'task_id': 'task-1', + 'package_id': 'android/org.fdroid.fdroid', + 'status': 'completed', + }, + }), + ), + (_) {}, + ); + } + return null; + }); + + const adapter = MethodChannelGetterAdapter( + channel: channel, + runtimeNotificationChannel: eventChannel, + ); + + final notification = await adapter.runtimeNotifications().first; + + expect(notification['kind'], 'task_changed'); + expect( + (notification['task'] as Map)['task_id'], + 'task-1', + ); + }); + + test('typed runtime update check returns getter-issued action id', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method != 'runtimeOperation') { + fail('unexpected method ${call.method}'); + } + final args = (call.arguments as Map) + .cast(); + if (args['operation'] == 'update_check_package_issue_action') { + return jsonEncode({ + 'ok': true, + 'command': 'runtime operation', + 'data': { + 'package': { + 'id': 'android/org.fdroid.fdroid', + 'name': 'F-Droid', + 'repository': 'official', + 'permissions': {'free_network': false}, + }, + 'update': { + 'network_required': false, + 'package_id': 'android/org.fdroid.fdroid', + 'installed_version': '1.0.0', + 'effective_local_version': '1.0.0', + 'policy': {'pin_version': null}, + 'status': 'update_available', + 'selected': { + 'package_id': 'android/org.fdroid.fdroid', + 'candidate': { + 'version': '1.2.0', + 'artifacts': [], + }, + }, + 'actions': [ + { + 'type': 'download', + 'url': 'https://example.invalid/app.apk', + 'file_name': 'app.apk', + }, + ], + }, + 'action': { + 'action_id': 'action-1', + 'package_id': 'android/org.fdroid.fdroid', + }, + }, + 'warnings': [], + }); + } + if (args['operation'] == 'task_submit') { + return jsonEncode({ + 'ok': true, + 'command': 'runtime operation', + 'data': _runtimeTaskJson('task-1', status: 'queued'), + 'warnings': [], + }); + } + fail('unexpected runtime operation ${args['operation']}'); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final update = await adapter.checkPackageForUpdate( + 'android/org.fdroid.fdroid', + repositoryId: 'official', + installedVersion: '1.0.0', + ); + final task = await adapter.submitRuntimeAction(update.action!.actionId); + + expect(update.action!.actionId, 'action-1'); + expect(update.update.selectedVersion, '1.2.0'); + expect(task.taskId, 'task-1'); + expect(calls.first.arguments, { + 'operation': 'update_check_package_issue_action', + 'payload': { + 'package_id': 'android/org.fdroid.fdroid', + 'repository_id': 'official', + 'installed_version': '1.0.0', + }, + }); + expect(calls.last.arguments, { + 'operation': 'task_submit', + 'payload': {'action_id': 'action-1'}, + }); + }); + + test('typed runtime task controls parse task snapshots', () async { + final operations = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = (call.arguments as Map) + .cast(); + operations.add(args['operation']! as String); + return jsonEncode({ + 'ok': true, + 'command': 'runtime operation', + 'data': + args['operation'] == 'task_list' || + args['operation'] == 'task_clean' + ? { + 'tasks': [ + _runtimeTaskJson('task-1', status: 'running'), + ], + } + : _runtimeTaskJson('task-1', status: 'running'), + 'warnings': [], + }); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final tasks = await adapter.listRuntimeTasks(active: true); + final canceled = await adapter.cancelRuntimeTask('task-1'); + final cleaned = await adapter.cleanRuntimeTasks(); + + expect(tasks.single.status, 'running'); + expect(canceled.taskId, 'task-1'); + expect(cleaned.single.taskId, 'task-1'); + expect(operations, ['task_list', 'task_cancel', 'task_clean']); + }); + + test('native runtime operation forwards operation and payload', () async { + MethodCall? captured; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + captured = call; + return jsonEncode({ + 'ok': true, + 'command': 'runtime operation', + 'data': { + 'task_id': 'task-1', + 'package_id': 'android/org.fdroid.fdroid', + 'status': 'completed', + 'phase': {'category': 'completed'}, + 'capabilities': { + 'cancel': false, + 'pause': false, + 'resume': false, + 'retry': false, + }, + 'updated_at': 1, + }, + 'warnings': [], + }); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + final data = await adapter.invokeRuntimeOperation( + 'task_get', + payload: const {'task_id': 'task-1'}, + ); + + expect(captured!.method, 'runtimeOperation'); + expect(captured!.arguments, { + 'operation': 'task_get', + 'payload': {'task_id': 'task-1'}, + }); + expect(data['status'], 'completed'); + }); + + test( + 'native adapter maps getter error envelope to bridge exception', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + return jsonEncode({ + 'ok': false, + 'command': call.method, + 'error': { + 'code': 'autogen.preview_error', + 'message': 'Preview failed', + 'detail': 'bad inventory', + }, + }); + }); + + const adapter = MethodChannelGetterAdapter(channel: channel); + + await expectLater( + adapter.previewInstalledAutogen(), + throwsA( + isA().having( + (error) => error.error.code, + 'code', + 'autogen.preview_error', + ), + ), + ); + }, + ); +} + +Map _runtimeTaskJson( + String taskId, { + required String status, +}) => { + 'task_id': taskId, + 'package_id': 'android/org.fdroid.fdroid', + 'status': status, + 'phase': {'category': status}, + 'progress': null, + 'capabilities': { + 'cancel': true, + 'pause': false, + 'resume': false, + 'retry': false, + }, + 'current_diagnostic': null, + 'updated_at': 1, +}; + +Map _previewJson() => { + 'operation': 'installed.preview', + 'target_repo_id': 'local_autogen', + 'target_repo_path': '/getter/repositories/local_autogen', + 'scan': { + 'stats': { + 'total_seen': 2, + 'returned': 1, + 'filtered_system': 1, + 'filtered_self': 0, + }, + 'diagnostics': [], + }, + 'summary': { + 'candidate_count': 1, + 'skipped_count': 0, + 'write_count': 1, + 'delete_count': 0, + }, + 'candidates': [ + { + 'package_id': 'android/com.example.autogen', + 'kind': 'android', + 'display_name': 'Example Autogen', + 'installed_target': { + 'kind': 'android_package', + 'package_name': 'com.example.autogen', + }, + 'action': 'create', + 'output_relative_path': 'packages/android/com.example.autogen.lua', + 'content_hash': 'fnv1a64:fake', + 'content': '-- fake generated content', + }, + ], + 'skipped': [], + 'diagnostics': [], +}; diff --git a/app_flutter/test/widget_test.dart b/app_flutter/test/widget_test.dart new file mode 100644 index 000000000..b1d56d0f0 --- /dev/null +++ b/app_flutter/test/widget_test.dart @@ -0,0 +1,571 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:upgradeall/getter_adapter.dart'; +import 'package:upgradeall/legacy_migration_platform.dart'; +import 'package:upgradeall/main.dart'; + +void main() { + testWidgets('fresh launch exposes home route and getter state', ( + tester, + ) async { + await tester.pumpWidget(const UpgradeAllApp()); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.homeRoute), findsOneWidget); + expect(find.byKey(AppKeys.updateSummary), findsOneWidget); + expect(find.byKey(AppKeys.getterStatus), findsOneWidget); + expect(find.text('0 updates available'), findsOneWidget); + expect(find.text('Fake getter ready'), findsOneWidget); + }); + + testWidgets('app list and detail routes use stable keys', (tester) async { + await tester.pumpWidget(const UpgradeAllApp()); + + await tester.tap(find.byKey(AppKeys.openApps)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.appsRoute), findsOneWidget); + expect(find.byKey(AppKeys.appsList), findsOneWidget); + expect( + find.byKey(AppKeys.appRow('android/org.fdroid.fdroid')), + findsOneWidget, + ); + expect(find.text('Network'), findsOneWidget); + + await tester.tap(find.byKey(AppKeys.appRow('android/org.fdroid.fdroid'))); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.appDetailRoute), findsOneWidget); + expect(find.text('android/org.fdroid.fdroid'), findsOneWidget); + expect(find.text('Installed: 1.20.0'), findsOneWidget); + expect(find.text('Latest: 1.20.0'), findsOneWidget); + expect(find.text('Network access required'), findsOneWidget); + }); + + testWidgets('app detail submits getter-issued update action to runtime', ( + tester, + ) async { + final getter = _UpdateCheckRecordingGetterAdapter(); + await tester.pumpWidget(UpgradeAllApp(getter: getter)); + + await tester.tap(find.byKey(AppKeys.openApps)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.appRow('android/org.fdroid.fdroid'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(AppKeys.checkPackageUpdate('android/org.fdroid.fdroid')), + ); + await tester.pumpAndSettle(); + + expect(getter.checkedPackageId, 'android/org.fdroid.fdroid'); + expect(getter.checkedInstalledVersion, '1.20.0'); + expect(getter.submittedActionId, 'action-from-getter'); + expect(find.byKey(AppKeys.downloadsRoute), findsOneWidget); + expect( + find.byKey(AppKeys.downloadTaskRow('task-from-action')), + findsOneWidget, + ); + }); + + testWidgets('app detail reports update checks without runtime action', ( + tester, + ) async { + await tester.pumpWidget( + UpgradeAllApp(getter: _NoUpdateActionGetterAdapter()), + ); + + await tester.tap(find.byKey(AppKeys.openApps)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.appRow('android/org.fdroid.fdroid'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(AppKeys.checkPackageUpdate('android/org.fdroid.fdroid')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.appDetailRoute), findsOneWidget); + expect(find.byKey(AppKeys.updateCheckStatus), findsOneWidget); + expect(find.text('No update task available: up_to_date'), findsOneWidget); + expect(find.byKey(AppKeys.downloadsRoute), findsNothing); + }); + + testWidgets('repository route lists priority ordered repository IDs', ( + tester, + ) async { + await tester.pumpWidget(const UpgradeAllApp()); + + await tester.tap(find.byKey(AppKeys.openRepositories)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.repositoriesRoute), findsOneWidget); + expect(find.byKey(AppKeys.repositoriesList), findsOneWidget); + expect(find.byKey(AppKeys.repoRow('local')), findsOneWidget); + expect(find.byKey(AppKeys.repoRow('official')), findsOneWidget); + expect(find.byKey(AppKeys.repoRow('local_autogen')), findsOneWidget); + }); + + testWidgets('downloads route renders runtime task snapshots read-only', ( + tester, + ) async { + await tester.pumpWidget(const UpgradeAllApp()); + + await tester.tap(find.byKey(AppKeys.openDownloads)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.downloadsRoute), findsOneWidget); + expect(find.byKey(AppKeys.downloadsList), findsOneWidget); + expect(find.byKey(AppKeys.downloadTaskRow('task-1')), findsOneWidget); + expect(find.text('queued • queued'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('downloads route refreshes after runtime notification', ( + tester, + ) async { + final getter = _NotificationRefreshingGetterAdapter(); + await tester.pumpWidget(UpgradeAllApp(getter: getter)); + + await tester.tap(find.byKey(AppKeys.openDownloads)); + await tester.pumpAndSettle(); + expect(find.text('queued • queued'), findsOneWidget); + + getter.emitRunningTaskNotification(); + await tester.pumpAndSettle(); + + expect(find.text('running • download'), findsOneWidget); + expect(getter.listCallCount, 2); + }); + + testWidgets('downloads route exposes getter empty task state', ( + tester, + ) async { + await tester.pumpWidget( + const UpgradeAllApp(getter: _NoTaskGetterAdapter()), + ); + + await tester.tap(find.byKey(AppKeys.openDownloads)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.downloadsRoute), findsOneWidget); + expect(find.byKey(AppKeys.downloadsEmpty), findsOneWidget); + }); + + testWidgets('migration route imports prepared legacy DB through getter', ( + tester, + ) async { + final getter = _MigrationGetterAdapter(); + await tester.pumpWidget( + UpgradeAllApp( + getter: getter, + legacyMigrationPlatform: const _PreparedLegacyMigrationPlatform( + '/tmp/app_metadata_database.db', + ), + ), + ); + + await tester.tap(find.byKey(AppKeys.openMigration)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.startLegacyMigration)); + await tester.pumpAndSettle(); + + expect(getter.importedDatabasePath, '/tmp/app_metadata_database.db'); + expect(find.byKey(AppKeys.migrationStatus), findsOneWidget); + expect(find.text('Legacy migration imported 1 records'), findsOneWidget); + expect(find.byKey(AppKeys.migrationImported), findsOneWidget); + expect(find.byKey(AppKeys.migrationReportsList), findsOneWidget); + expect(find.text('migration.imported'), findsOneWidget); + }); + + testWidgets( + 'migration route reports missing legacy DB from platform adapter', + (tester) async { + await tester.pumpWidget( + const UpgradeAllApp( + getter: _LegacyMigrationCapableGetterAdapter(), + legacyMigrationPlatform: _MissingLegacyMigrationPlatform(), + ), + ); + + await tester.tap(find.byKey(AppKeys.openMigration)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.startLegacyMigration)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.migrationStatus), findsOneWidget); + expect(find.text('No legacy Room database found'), findsOneWidget); + expect(find.byKey(AppKeys.migrationImported), findsNothing); + }, + ); + + testWidgets('installed autogen route previews and applies getter DTOs', ( + tester, + ) async { + final getter = _AutogenRecordingGetterAdapter(); + await tester.pumpWidget(UpgradeAllApp(getter: getter)); + + await tester.drag(find.byType(ListView).first, const Offset(0, -240)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.openInstalledAutogen)); + await tester.pumpAndSettle(); + expect(find.byKey(AppKeys.installedAutogenRoute), findsOneWidget); + expect(find.byKey(AppKeys.installedAutogenReady), findsOneWidget); + + await tester.tap(find.byKey(AppKeys.previewInstalledAutogen)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.installedAutogenPreview), findsOneWidget); + expect(find.byKey(AppKeys.installedAutogenScanStats), findsOneWidget); + expect( + find.byKey(AppKeys.autogenCandidateRow('android/com.example.autogen')), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.autogenSkipRow('android/org.fdroid.fdroid')), + findsOneWidget, + ); + + await tester.tap(find.byKey(AppKeys.applyInstalledAutogen)); + await tester.pumpAndSettle(); + + expect(find.byKey(AppKeys.installedAutogenApplied), findsOneWidget); + expect( + find.byKey(AppKeys.autogenAppliedRow('android/com.example.autogen')), + findsOneWidget, + ); + expect(getter.acceptedPackageIds, ['android/com.example.autogen']); + }); + + testWidgets('installed autogen route disables actions without bridge', ( + tester, + ) async { + await tester.pumpWidget( + const UpgradeAllApp(getter: _NoInstalledAutogenGetterAdapter()), + ); + + await tester.drag(find.byType(ListView).first, const Offset(0, -240)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.openInstalledAutogen)); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.byKey(AppKeys.previewInstalledAutogen), + ); + expect(button.onPressed, isNull); + expect( + find.byKey(AppKeys.installedAutogenBridgeUnavailable), + findsOneWidget, + ); + }); + + testWidgets('migration route disables import when getter bridge is absent', ( + tester, + ) async { + await tester.pumpWidget( + const UpgradeAllApp( + legacyMigrationPlatform: _PreparedLegacyMigrationPlatform( + '/tmp/app_metadata_database.db', + ), + ), + ); + + await tester.tap(find.byKey(AppKeys.openMigration)); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.byKey(AppKeys.startLegacyMigration), + ); + expect(button.onPressed, isNull); + expect(find.byKey(AppKeys.migrationBridgeUnavailable), findsOneWidget); + }); + + testWidgets('placeholder routes expose stable empty-state keys', ( + tester, + ) async { + await tester.pumpWidget(const UpgradeAllApp()); + + await tester.tap(find.byKey(AppKeys.openLogs)); + await tester.pumpAndSettle(); + expect(find.byKey(AppKeys.logsRoute), findsOneWidget); + expect(find.byKey(AppKeys.logsEmpty), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.openSettings)); + await tester.pumpAndSettle(); + expect(find.byKey(AppKeys.settingsRoute), findsOneWidget); + expect(find.byKey(AppKeys.settingsShell), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(AppKeys.openMigration)); + await tester.pumpAndSettle(); + expect(find.byKey(AppKeys.migrationRoute), findsOneWidget); + expect(find.byKey(AppKeys.migrationReady), findsOneWidget); + }); +} + +class _UpdateCheckRecordingGetterAdapter extends FakeGetterAdapter { + String? checkedPackageId; + String? checkedInstalledVersion; + String? submittedActionId; + final _tasks = []; + + @override + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }) async { + checkedPackageId = packageId; + checkedInstalledVersion = installedVersion; + return RuntimeUpdateCheckResult.fromJson({ + 'package': { + 'id': packageId, + 'name': 'F-Droid', + 'repository': repositoryId ?? 'official', + }, + 'update': { + 'package_id': packageId, + 'status': 'update_available', + 'installed_version': installedVersion, + 'effective_local_version': installedVersion, + 'selected': { + 'candidate': {'version': '1.21.0'}, + }, + 'actions': [ + {'type': 'download'}, + ], + }, + 'action': { + 'action_id': 'action-from-getter', + 'package_id': packageId, + }, + }); + } + + @override + Future submitRuntimeAction(String actionId) async { + submittedActionId = actionId; + final task = RuntimeTaskSnapshot.fromJson(const { + 'task_id': 'task-from-action', + 'package_id': 'android/org.fdroid.fdroid', + 'status': 'queued', + 'phase': {'category': 'queued'}, + 'progress': null, + 'capabilities': { + 'cancel': true, + 'pause': false, + 'resume': false, + 'retry': false, + }, + 'current_diagnostic': null, + 'updated_at': 42, + }); + _tasks + ..clear() + ..add(task); + return task; + } + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) async => List.unmodifiable(_tasks); +} + +class _NoUpdateActionGetterAdapter extends FakeGetterAdapter { + const _NoUpdateActionGetterAdapter(); + + @override + Future checkPackageForUpdate( + String packageId, { + String? repositoryId, + String? installedVersion, + String? pinVersion, + }) async { + return RuntimeUpdateCheckResult.fromJson({ + 'package': { + 'id': packageId, + 'name': 'F-Droid', + 'repository': repositoryId ?? 'official', + }, + 'update': { + 'package_id': packageId, + 'status': 'up_to_date', + 'installed_version': installedVersion, + 'effective_local_version': installedVersion, + 'selected': null, + 'actions': [], + }, + 'action': null, + }); + } +} + +class _NotificationRefreshingGetterAdapter extends FakeGetterAdapter { + final _notifications = + StreamController.broadcast(); + var _running = false; + var listCallCount = 0; + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) async { + listCallCount += 1; + return [_task(_running ? 'running' : 'queued')]; + } + + @override + Stream runtimeNotificationEnvelopes() { + return _notifications.stream; + } + + void emitRunningTaskNotification() { + _running = true; + _notifications.add( + RuntimeNotificationEnvelope(kind: 'task_changed', task: _task('running')), + ); + } + + RuntimeTaskSnapshot _task(String status) { + return RuntimeTaskSnapshot.fromJson({ + 'task_id': 'task-refresh', + 'package_id': 'android/org.fdroid.fdroid', + 'status': status, + 'phase': { + 'category': status == 'running' ? 'download' : 'queued', + }, + 'progress': null, + 'capabilities': { + 'cancel': true, + 'pause': false, + 'resume': false, + 'retry': false, + }, + 'current_diagnostic': null, + 'updated_at': status == 'running' ? 2 : 1, + }); + } +} + +class _NoTaskGetterAdapter extends FakeGetterAdapter { + const _NoTaskGetterAdapter(); + + @override + Future> listRuntimeTasks({ + bool active = false, + String? packageId, + }) async => const []; +} + +class _LegacyMigrationCapableGetterAdapter extends FakeGetterAdapter { + const _LegacyMigrationCapableGetterAdapter(); + + @override + bool get supportsLegacyRoomImport => true; +} + +class _NoInstalledAutogenGetterAdapter extends FakeGetterAdapter { + const _NoInstalledAutogenGetterAdapter(); + + @override + bool get supportsInstalledAutogen => false; +} + +class _AutogenRecordingGetterAdapter extends FakeGetterAdapter { + List? acceptedPackageIds; + + @override + Future applyInstalledAutogen( + InstalledAutogenPreview preview, { + List? acceptedPackageIds, + }) { + this.acceptedPackageIds = acceptedPackageIds; + return super.applyInstalledAutogen( + preview, + acceptedPackageIds: acceptedPackageIds, + ); + } +} + +class _MigrationGetterAdapter extends FakeGetterAdapter { + String? importedDatabasePath; + @override + bool get supportsLegacyRoomImport => true; + var _reports = const []; + + @override + Future importLegacyRoomDatabase( + String databasePath, + ) async { + importedDatabasePath = databasePath; + _reports = const [ + MigrationReportSummary( + ok: true, + code: 'migration.imported', + message: 'Legacy Room database imported', + importedRecords: 1, + trackedRecords: 1, + ), + ]; + return const LegacyMigrationImportResult( + alreadyImported: false, + importedRecords: 1, + trackedPackages: [ + TrackedPackageSummary( + id: 'android/org.fdroid.fdroid', + enabled: true, + favorite: true, + pinVersion: '1.20.0', + repositoryId: null, + packageResolution: 'missing_package_definition', + ), + ], + warnings: [], + sourceCounts: MigrationSourceCounts( + appRows: 1, + extraAppRows: 1, + hubRows: 0, + extraHubRows: 0, + ), + ); + } + + @override + Future> readMigrationReports() async => _reports; +} + +class _PreparedLegacyMigrationPlatform implements LegacyMigrationPlatform { + const _PreparedLegacyMigrationPlatform(this.databasePath); + + final String databasePath; + + @override + Future prepareLegacyRoomImport() async { + return LegacyRoomImportCandidate( + found: true, + databasePath: databasePath, + message: 'Legacy Room database prepared', + ); + } +} + +class _MissingLegacyMigrationPlatform implements LegacyMigrationPlatform { + const _MissingLegacyMigrationPlatform(); + + @override + Future prepareLegacyRoomImport() async { + return const LegacyRoomImportCandidate( + found: false, + databasePath: null, + message: 'No legacy Room database found', + ); + } +} diff --git a/core-getter/consumer-rules.pro b/core-getter/consumer-rules.pro index e69de29bb..56e59b643 100644 --- a/core-getter/consumer-rules.pro +++ b/core-getter/consumer-rules.pro @@ -0,0 +1,5 @@ +# Flutter/Kotlin calls these JNI entrypoints by their native method names. +-keep class net.xzos.upgradeall.getter.NativeLib { *; } + +# Rust JNI loads this provider reflectively through the app classloader. +-keep class net.xzos.upgradeall.getter.platform.** { *; } diff --git a/core-getter/rpc/build.gradle.kts b/core-getter/rpc/build.gradle.kts index 601180c15..4517a654f 100644 --- a/core-getter/rpc/build.gradle.kts +++ b/core-getter/rpc/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("java-library") alias(libs.plugins.kotlin.jvm) @@ -8,6 +10,12 @@ java { targetCompatibility = JavaVersion.VERSION_21 } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + dependencies { api(project(":core-websdk:data")) diff --git a/core-getter/src/main/java/net/xzos/upgradeall/getter/NativeLib.kt b/core-getter/src/main/java/net/xzos/upgradeall/getter/NativeLib.kt index 8223222a9..d9d843be3 100644 --- a/core-getter/src/main/java/net/xzos/upgradeall/getter/NativeLib.kt +++ b/core-getter/src/main/java/net/xzos/upgradeall/getter/NativeLib.kt @@ -14,7 +14,16 @@ class NativeLib { * A native method that is implemented by the 'getter' native library, * which is packaged with this application. */ - external fun runServer(context:Context, callback: RunServerCallback): String + external fun runServer(context: Context, callback: RunServerCallback): String + external fun initializeBridge(context: Context): String + external fun previewInstalledAutogen(context: Context, requestJson: String): String + external fun applyInstalledAutogen(requestJson: String): String + external fun importLegacyRoomDatabase(requestJson: String): String + external fun legacyReportList(requestJson: String): String + external fun readOperation(requestJson: String): String + external fun runtimeOperation(requestJson: String): String + external fun drainRuntimeNotifications(): String + fun runServerLambda(context: Context, callback: (String) -> Unit): String { return runServer(context, RunServerCallback(callback)) } diff --git a/core-getter/src/main/java/net/xzos/upgradeall/getter/platform/InstalledInventoryProvider.kt b/core-getter/src/main/java/net/xzos/upgradeall/getter/platform/InstalledInventoryProvider.kt new file mode 100644 index 000000000..544f2d75c --- /dev/null +++ b/core-getter/src/main/java/net/xzos/upgradeall/getter/platform/InstalledInventoryProvider.kt @@ -0,0 +1,261 @@ +package net.xzos.upgradeall.getter.platform + +import android.Manifest +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import org.json.JSONArray +import org.json.JSONObject + +private const val INSTALLED_INVENTORY_FORMAT = "upgradeall-installed-inventory" +private const val INSTALLED_INVENTORY_VERSION = 1 + +/** + * JNI entrypoint used by Rust's platform adapter. + * + * Kotlin returns raw Android PackageManager facts only. It does not construct + * UpgradeAll package ids, decide repository coverage, generate Lua, or write + * getter storage. + */ +@Suppress("unused") +object InstalledInventoryProvider { + @JvmStatic + fun scanInstalledInventory(context: Context, optionsJson: String): String { + val options = InstalledInventoryJson.decodeOptions(optionsJson) + val result = InstalledInventoryScanner.scan(context.applicationContext ?: context, options) + return InstalledInventoryJson.encodeResult(result) + } +} + +object InstalledInventoryScanner { + fun scan( + context: Context, + options: InstalledInventoryScanOptions = InstalledInventoryScanOptions(), + ): InstalledInventoryScanResult { + val packageManager = context.packageManager + val rawPackages = getInstalledPackages(packageManager).map { packageInfo -> + packageInfo.toRawInstalledPackage(packageManager) + } + val result = InstalledInventoryCollector.collect( + selfPackageName = context.packageName, + packages = rawPackages, + options = options, + ) + val diagnostics = result.diagnostics.toMutableList() + if (!declaresQueryAllPackages(context)) { + diagnostics += PlatformDiagnostic( + code = "package_visibility.query_all_packages_missing", + message = "QUERY_ALL_PACKAGES is not declared; installed app inventory may be incomplete.", + ) + } + return result.copy(diagnostics = diagnostics) + } + + @Suppress("DEPRECATION") + private fun getInstalledPackages(packageManager: PackageManager): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0)) + } else { + packageManager.getInstalledPackages(0) + } + } + + private fun PackageInfo.toRawInstalledPackage(packageManager: PackageManager): RawInstalledPackage { + val appInfo = applicationInfo + return RawInstalledPackage( + packageName = packageName.orEmpty(), + label = appInfo.safeLabel(packageManager), + versionName = versionName?.takeIf { it.isNotBlank() }, + versionCode = packageVersionCode(), + isSystem = appInfo.isSystemPackage(), + ) + } + + private fun ApplicationInfo?.safeLabel(packageManager: PackageManager): String? { + return try { + this?.loadLabel(packageManager)?.toString()?.takeIf { it.isNotBlank() } + } catch (_: RuntimeException) { + null + } + } + + private fun ApplicationInfo?.isSystemPackage(): Boolean { + val flags = this?.flags ?: return false + return flags and ApplicationInfo.FLAG_SYSTEM != 0 || + flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + } + + @Suppress("DEPRECATION") + private fun PackageInfo.packageVersionCode(): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode + } else { + versionCode.toLong() + } + } + + private fun declaresQueryAllPackages(context: Context): Boolean { + return try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } + packageInfo.requestedPermissions?.contains(Manifest.permission.QUERY_ALL_PACKAGES) == true + } catch (_: RuntimeException) { + false + } + } +} + +object InstalledInventoryCollector { + fun collect( + selfPackageName: String, + packages: List, + options: InstalledInventoryScanOptions, + ): InstalledInventoryScanResult { + var filteredSystem = 0 + var filteredSelf = 0 + val itemsByPackageName = linkedMapOf() + + for (rawPackage in packages) { + val packageName = rawPackage.packageName.trim() + if (packageName.isEmpty()) { + continue + } + if (!options.includeSelf && packageName == selfPackageName) { + filteredSelf++ + continue + } + if (!options.includeSystemApps && rawPackage.isSystem) { + filteredSystem++ + continue + } + itemsByPackageName[packageName] = InstalledInventoryItem( + packageName = packageName, + label = rawPackage.label?.takeIf { it.isNotBlank() }, + versionName = rawPackage.versionName?.takeIf { it.isNotBlank() }, + versionCode = rawPackage.versionCode, + ) + } + + val items = itemsByPackageName.values.sortedBy { it.packageName } + return InstalledInventoryScanResult( + inventory = InstalledInventory(items = items), + stats = InstalledInventoryScanStats( + totalSeen = packages.size, + returned = items.size, + filteredSystem = filteredSystem, + filteredSelf = filteredSelf, + ), + ) + } +} + +data class InstalledInventoryScanOptions( + val includeSystemApps: Boolean = false, + val includeSelf: Boolean = false, +) + +data class RawInstalledPackage( + val packageName: String, + val label: String? = null, + val versionName: String? = null, + val versionCode: Long? = null, + val isSystem: Boolean = false, +) + +data class InstalledInventoryScanResult( + val inventory: InstalledInventory, + val stats: InstalledInventoryScanStats, + val diagnostics: List = emptyList(), +) + +data class InstalledInventory( + val format: String = INSTALLED_INVENTORY_FORMAT, + val version: Int = INSTALLED_INVENTORY_VERSION, + val items: List = emptyList(), +) + +data class InstalledInventoryItem( + val packageName: String, + val label: String? = null, + val versionName: String? = null, + val versionCode: Long? = null, +) + +data class InstalledInventoryScanStats( + val totalSeen: Int, + val returned: Int, + val filteredSystem: Int, + val filteredSelf: Int, +) + +data class PlatformDiagnostic( + val code: String, + val message: String, + val detail: String? = null, +) + +object InstalledInventoryJson { + fun decodeOptions(json: String): InstalledInventoryScanOptions { + val value = if (json.isBlank()) JSONObject() else JSONObject(json) + return InstalledInventoryScanOptions( + includeSystemApps = value.optBoolean("include_system_apps", false), + includeSelf = value.optBoolean("include_self", false), + ) + } + + fun encodeResult(result: InstalledInventoryScanResult): String { + return JSONObject() + .put("inventory", encodeInventory(result.inventory)) + .put("stats", encodeStats(result.stats)) + .put("diagnostics", JSONArray().also { diagnostics -> + result.diagnostics.forEach { diagnostics.put(encodeDiagnostic(it)) } + }) + .toString() + } + + private fun encodeInventory(inventory: InstalledInventory): JSONObject { + return JSONObject() + .put("format", inventory.format) + .put("version", inventory.version) + .put("items", JSONArray().also { items -> + inventory.items.forEach { items.put(encodeItem(it)) } + }) + } + + private fun encodeItem(item: InstalledInventoryItem): JSONObject { + return JSONObject() + .put("kind", "android_package") + .put("package_name", item.packageName) + .putNullable("label", item.label) + .putNullable("version_name", item.versionName) + .putNullable("version_code", item.versionCode) + } + + private fun encodeStats(stats: InstalledInventoryScanStats): JSONObject { + return JSONObject() + .put("total_seen", stats.totalSeen) + .put("returned", stats.returned) + .put("filtered_system", stats.filteredSystem) + .put("filtered_self", stats.filteredSelf) + } + + private fun encodeDiagnostic(diagnostic: PlatformDiagnostic): JSONObject { + return JSONObject() + .put("code", diagnostic.code) + .put("message", diagnostic.message) + .putNullable("detail", diagnostic.detail) + } + + private fun JSONObject.putNullable(name: String, value: Any?): JSONObject { + return put(name, value ?: JSONObject.NULL) + } +} diff --git a/core-getter/src/main/rust/api_proxy/Cargo.toml b/core-getter/src/main/rust/api_proxy/Cargo.toml index 5a1469ceb..b941d6b01 100644 --- a/core-getter/src/main/rust/api_proxy/Cargo.toml +++ b/core-getter/src/main/rust/api_proxy/Cargo.toml @@ -7,16 +7,20 @@ edition = "2021" [dependencies] jni = "0.21" # from rustls-platform-verifier-android, sync version -getter = { path = "../getter", features = ["rustls-platform-verifier-android"] } +getter = { path = "../getter", default-features = false, features = ["domain", "lua", "native-tokio", "rustls-platform-verifier-android"] } +upgradeall-platform-adapter = { path = "../platform_adapter" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" -tokio = "1.48.0" +thiserror = "1" +tokio = { version = "1.48.0", features = ["rt-multi-thread"] } + +[dev-dependencies] +tempfile = "3" [lib] crate-type = ["cdylib"] [profile.release] -crate-type = ["rlib", "cdylib"] strip = true opt-level = 3 lto = true diff --git a/core-getter/src/main/rust/api_proxy/src/lib.rs b/core-getter/src/main/rust/api_proxy/src/lib.rs index 57b924c36..7e2e05c87 100644 --- a/core-getter/src/main/rust/api_proxy/src/lib.rs +++ b/core-getter/src/main/rust/api_proxy/src/lib.rs @@ -1,72 +1,141 @@ extern crate jni; +use getter::operations::autogen::{self, AutogenAcceptance, AutogenOperationError}; +use getter::operations::legacy_room::{self, LegacyRoomOperationError}; +use getter::operations::read_model::{self, ReadModelOperationError}; +use getter::operations::runtime as runtime_operations; use getter::rpc::server::run_server_hanging; #[cfg(target_os = "android")] use getter::rustls_platform_verifier; -use jni::objects::{JClass, JObject, JString, JValue}; +use jni::objects::{JObject, JString, JValue}; use jni::JNIEnv; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; use std::sync::mpsc::channel; +use std::sync::{Mutex, OnceLock}; use std::thread; +use upgradeall_platform_adapter::InstalledInventoryScanOptions; +#[cfg(target_os = "android")] +use upgradeall_platform_adapter::PlatformAdapter; + +const MAIN_DB_FILE: &str = "main.db"; +const CACHE_DB_FILE: &str = "cache.db"; +const MAX_RUNTIME_NOTIFICATION_QUEUE: usize = 64; + +static GETTER_RUNTIME: OnceLock> = OnceLock::new(); +static RUNTIME_NOTIFICATIONS: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Deserialize)] +struct PreviewInstalledAutogenRequest { + data_dir: PathBuf, + #[serde(default)] + scan_options: InstalledInventoryScanOptions, +} + +#[derive(Debug, Deserialize)] +struct ApplyInstalledAutogenRequest { + data_dir: PathBuf, + preview: Value, + #[serde(default)] + acceptance: ApplyInstalledAutogenAcceptance, +} + +#[derive(Debug, Deserialize)] +struct ImportLegacyRoomDatabaseRequest { + data_dir: PathBuf, + database_path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct LegacyReportListRequest { + data_dir: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct ReadOperationRequest { + data_dir: PathBuf, + operation: String, + #[serde(default)] + payload: Value, +} + +#[derive(Debug, Deserialize)] +struct RuntimeOperationRequest { + operation: String, + #[serde(default)] + payload: Value, + #[serde(default)] + data_dir: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct ApplyInstalledAutogenAcceptance { + #[serde(default)] + mode: Option, + #[serde(default)] + package_ids: Vec, +} #[no_mangle] pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_runServer<'local>( mut env: JNIEnv<'local>, - _: JClass<'local>, - _context: JObject, + _: JObject<'local>, + context: JObject<'local>, callback: JObject<'local>, ) -> JString<'local> { - // Initialize the certificate verifier for future use. - // https://github.com/rustls/rustls-platform-verifier/tree/3edb4d278215a8603020351b8b519d907a26041f?tab=readme-ov-file#crate-initialization - #[cfg(target_os = "android")] - match rustls_platform_verifier::android::init_hosted(&mut env, _context) { - Ok(_) => {} - Err(e) => { - return env - .new_string(format!("Error initializing certificate verifier: {}", e)) - .expect("Failed to create Java string"); - } + if let Err(error) = init_android_integrations(&mut env, &context) { + return java_string_or_fallback(&mut env, error); } - let (url_tx, url_rx) = channel(); - let (completion_tx, completion_rx) = channel::>(); + + let (startup_tx, startup_rx) = channel::>(); thread::spawn(move || { let runtime = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { - let err_msg = format!("Error creating Tokio runtime: {}", e); - completion_tx.send(Some(err_msg)).unwrap(); + let _ = startup_tx.send(Err(format!("Error creating Tokio runtime: {}", e))); return; } }; runtime.block_on(async move { let address = "127.0.0.1:0"; - match run_server_hanging(address, |url| { - url_tx.send(url.to_string()).unwrap(); + let startup_error_tx = startup_tx.clone(); + if let Err(e) = run_server_hanging(address, move |url| { + startup_tx + .send(Ok(url.to_string())) + .map_err(|_| getter::rpc::server::RpcServerError::StartupCallback)?; Ok(()) }) .await { - Ok(_) => completion_tx.send(None).unwrap(), // No error, send completion signal - Err(e) => { - let err_msg = format!("Error running server: {}", e); - completion_tx.send(Some(err_msg)).unwrap(); - } + // If startup failed before the URL callback, report it to JNI. + // If startup succeeded, NativeLib.runServer has already returned + // to Kotlin and the placeholder server intentionally lives for + // the lifetime of this background thread. + let _ = startup_error_tx.send(Err(format!("Error running server: {}", e))); } }); }); - let url = match url_rx.recv() { - Ok(url) => url, + let url = match startup_rx.recv() { + Ok(Ok(url)) => url, + Ok(Err(error)) => { + return java_string_or_fallback(&mut env, error); + } Err(e) => { - return env - .new_string(format!("Error receiving URL from server thread: {}", e)) - .expect("Failed to create Java string"); + return java_string_or_fallback( + &mut env, + format!("Error receiving URL from server thread: {}", e), + ); } }; let jurl = match env.new_string(url) { Ok(jurl) => jurl, Err(e) => { - return env - .new_string(format!("Error creating URL Java string: {}", e)) - .expect("Failed to create Java string"); + return java_string_or_fallback( + &mut env, + format!("Error creating URL Java string: {e}"), + ); } }; let call_result = env.call_method( @@ -77,23 +146,850 @@ pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_runServer<'local>( ); if let Err(e) = call_result { - return env - .new_string(format!("JNI call error: {}", e)) - .expect("Failed to create Java string"); + return java_string_or_fallback(&mut env, format!("JNI call error: {e}")); } - let error = match completion_rx.recv() { - Ok(error) => error, - Err(e) => { - return env - .new_string(format!("Error receiving error from server thread: {}", e)) - .expect("Failed to create Java string"); + java_string_or_fallback(&mut env, "") +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_initializeBridge<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + context: JObject<'local>, +) -> JString<'local> { + let response = match init_android_integrations(&mut env, &context) { + Ok(()) => { + init_getter_runtime(); + success_envelope("bridge initialize", json!({ "initialized": true })) } + Err(error) => error_envelope( + "bridge initialize", + "bridge.initialize_error", + "Getter native bridge initialization failed", + Some(error), + ), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_previewInstalledAutogen<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + context: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "autogen installed preview"; + let response = match jstring_to_string(&mut env, &request_json) + .and_then(|raw| preview_installed_autogen(&mut env, &context, &raw)) + { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_applyInstalledAutogen<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "autogen installed apply"; + let response = match jstring_to_string(&mut env, &request_json) + .and_then(|raw| apply_installed_autogen(&raw)) + { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_runtimeOperation<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "runtime operation"; + let response = match jstring_to_string(&mut env, &request_json).and_then(runtime_operation) { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_drainRuntimeNotifications<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, +) -> JString<'local> { + let command = "runtime notifications drain"; + let response = match drain_runtime_notifications() { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_importLegacyRoomDatabase<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "legacy import-room-db"; + let response = match jstring_to_string(&mut env, &request_json) + .and_then(|raw| import_legacy_room_database(&raw)) + { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_legacyReportList<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "legacy report-list"; + let response = + match jstring_to_string(&mut env, &request_json).and_then(|raw| legacy_report_list(&raw)) { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +#[no_mangle] +pub extern "C" fn Java_net_xzos_upgradeall_getter_NativeLib_readOperation<'local>( + mut env: JNIEnv<'local>, + _: JObject<'local>, + request_json: JString<'local>, +) -> JString<'local> { + let command = "read operation"; + let response = match jstring_to_string(&mut env, &request_json).and_then(read_operation) { + Ok(data) => success_envelope(command, data), + Err(error) => operation_error_envelope(command, error), + }; + java_string_or_fallback(&mut env, response) +} + +fn preview_installed_autogen( + env: &mut JNIEnv<'_>, + context: &JObject<'_>, + request_json: &str, +) -> Result { + init_android_integrations(env, context).map_err(BridgeOperationError::Initialize)?; + let request: PreviewInstalledAutogenRequest = serde_json::from_str(request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + let db = open_main_db(&request.data_dir)?; + let scan = scan_installed_inventory(request.scan_options)?; + let inventory: getter::core::autogen::InstalledInventory = + serde_json::to_value(&scan.inventory) + .and_then(serde_json::from_value) + .map_err(|source| BridgeOperationError::PlatformMalformed(source.to_string()))?; + let plan = autogen::build_local_autogen_plan(&db, &inventory)?; + let mut preview = autogen::installed_preview_json(&request.data_dir, &plan); + if let Some(object) = preview.as_object_mut() { + object.insert( + "scan".to_owned(), + json!({ + "stats": scan.stats, + "diagnostics": scan.diagnostics, + }), + ); + } + Ok(preview) +} + +fn apply_installed_autogen(request_json: &str) -> Result { + let request: ApplyInstalledAutogenRequest = serde_json::from_str(request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + let db = open_main_db(&request.data_dir)?; + let preview = autogen::unwrap_preview_payload(request.preview, "installed.preview")?; + let acceptance = request.acceptance.into_autogen_acceptance()?; + Ok(autogen::apply_installed_preview( + &request.data_dir, + &db, + &preview, + &acceptance, + )?) +} + +fn import_legacy_room_database(request_json: &str) -> Result { + let request: ImportLegacyRoomDatabaseRequest = serde_json::from_str(request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + legacy_room::import_room_db_json(&request.data_dir, &request.database_path) + .map_err(BridgeOperationError::from) +} + +fn legacy_report_list(request_json: &str) -> Result { + let request: LegacyReportListRequest = serde_json::from_str(request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + legacy_room::report_list_json(&request.data_dir).map_err(BridgeOperationError::from) +} + +fn init_getter_runtime() -> &'static Mutex { + GETTER_RUNTIME.get_or_init(|| { + let mut runtime = getter::core::runtime::GetterRuntime::new(); + runtime.set_notification_sink(|notification| { + enqueue_runtime_notification(notification); + }); + Mutex::new(runtime) + }) +} + +fn runtime_notification_queue() -> &'static Mutex> { + RUNTIME_NOTIFICATIONS.get_or_init(|| Mutex::new(VecDeque::new())) +} + +fn enqueue_runtime_notification(notification: getter::core::runtime::RuntimeNotification) { + let Ok(value) = serde_json::to_value(notification) else { + return; + }; + let Ok(mut queue) = runtime_notification_queue().lock() else { + return; + }; + if queue.len() >= MAX_RUNTIME_NOTIFICATION_QUEUE { + queue.pop_front(); + } + queue.push_back(value); +} + +fn drain_runtime_notifications() -> Result { + let mut queue = runtime_notification_queue() + .lock() + .map_err(|_| BridgeOperationError::RuntimeNotificationQueuePoisoned)?; + let notifications: Vec = queue.drain(..).collect(); + Ok(json!({ "notifications": notifications })) +} + +fn read_operation(request_json: String) -> Result { + let request: ReadOperationRequest = serde_json::from_str(&request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + let payload = if request.payload.is_null() { + "{}".to_owned() + } else { + request.payload.to_string() }; - match error { - None => env.new_string("").expect("Failed to create Java string"), - Some(error) => env - .new_string(format!("Error running server: {}", error)) - .expect("Failed to create Java string"), + match request.operation.as_str() { + "repository_list" => read_model::repository_list_json(&request.data_dir), + "tracked_package_list" => read_model::tracked_package_list_json(&request.data_dir), + "package_eval" => read_model::package_eval_json(&request.data_dir, &payload), + other => Err(ReadModelOperationError::InvalidRequest(format!( + "unsupported read operation '{other}'" + ))), + } + .map_err(BridgeOperationError::ReadModel) +} + +fn runtime_operation(request_json: String) -> Result { + let runtime = init_getter_runtime(); + let mut runtime = runtime + .lock() + .map_err(|_| BridgeOperationError::RuntimePoisoned)?; + runtime_operation_with_runtime(&mut runtime, &request_json) +} + +fn runtime_operation_with_runtime( + runtime: &mut getter::core::runtime::GetterRuntime, + request_json: &str, +) -> Result { + let request: RuntimeOperationRequest = serde_json::from_str(request_json) + .map_err(|source| BridgeOperationError::InvalidRequest(source.to_string()))?; + let payload = if request.payload.is_null() { + "{}".to_owned() + } else { + request.payload.to_string() + }; + match request.operation.as_str() { + "update_check_offline_issue_action" => { + runtime_operations::issue_action_from_offline_update_check_json(runtime, &payload) + } + "update_check_package_issue_action" => { + let data_dir = request.data_dir.as_ref().ok_or_else(|| { + BridgeOperationError::InvalidRequest( + "data_dir is required for package update checks".to_owned(), + ) + })?; + let db = open_main_db(data_dir)?; + runtime_operations::issue_action_from_registered_package_json(runtime, &db, &payload) + } + "task_submit" => runtime_operations::submit_action_json(runtime, &payload), + "task_get" => runtime_operations::task_get_json(runtime, &payload), + "task_list" => runtime_operations::task_list_json(runtime, &payload), + "task_start" => runtime_operations::task_start_json(runtime, &payload), + "task_download_progress" => { + runtime_operations::task_download_progress_json(runtime, &payload) + } + "task_complete_download" => { + runtime_operations::task_complete_download_json(runtime, &payload) + } + "task_pause" => runtime_operations::task_pause_json(runtime, &payload), + "task_resume" => runtime_operations::task_resume_json(runtime, &payload), + "task_user_result" => runtime_operations::task_user_result_json(runtime, &payload), + "task_cancel" => runtime_operations::task_cancel_json(runtime, &payload), + "task_retry" => runtime_operations::task_retry_json(runtime, &payload), + "task_remove" => runtime_operations::task_remove_json(runtime, &payload), + "task_clean" => runtime_operations::task_clean_json(runtime, &payload), + other => Err(runtime_operations::RuntimeOperationError::InvalidRequest( + format!("unsupported runtime operation '{other}'"), + )), + } + .map_err(BridgeOperationError::Runtime) +} + +impl ApplyInstalledAutogenAcceptance { + fn into_autogen_acceptance(self) -> Result { + match self.mode.as_deref().unwrap_or("all") { + "all" => Ok(AutogenAcceptance::AcceptAll), + "packages" => Ok(AutogenAcceptance::Accept(self.package_ids)), + other => Err(BridgeOperationError::InvalidRequest(format!( + "unsupported installed autogen acceptance mode '{other}'" + ))), + } + } +} + +fn open_main_db(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir) + .map_err(|source| BridgeOperationError::Storage(source.to_string()))?; + getter::storage::CacheDb::open(data_dir.join(CACHE_DB_FILE))?; + Ok(getter::storage::MainDb::open(data_dir.join(MAIN_DB_FILE))?) +} + +fn scan_installed_inventory( + options: InstalledInventoryScanOptions, +) -> Result { + #[cfg(target_os = "android")] + { + upgradeall_platform_adapter::android::AndroidPlatformAdapter + .scan_installed_inventory(options) + .map_err(BridgeOperationError::Platform) + } + #[cfg(not(target_os = "android"))] + { + let _ = options; + Err(BridgeOperationError::Platform( + upgradeall_platform_adapter::PlatformAdapterError::Unsupported { + capability: "installed_inventory.android", + }, + )) + } +} + +fn init_android_integrations(env: &mut JNIEnv<'_>, context: &JObject<'_>) -> Result<(), String> { + // Initialize Android-hosted Rust platform integrations for future use. + // https://github.com/rustls/rustls-platform-verifier/tree/3edb4d278215a8603020351b8b519d907a26041f?tab=readme-ov-file#crate-initialization + #[cfg(target_os = "android")] + { + let rustls_context = env + .new_local_ref(context) + .map_err(|e| format!("Error creating rustls context ref: {e}"))?; + rustls_platform_verifier::android::init_hosted(env, rustls_context) + .map_err(|e| format!("Error initializing certificate verifier: {e}"))?; + + let platform_context = env + .new_local_ref(context) + .map_err(|e| format!("Error creating platform adapter context ref: {e}"))?; + upgradeall_platform_adapter::android::init_with_env(env, platform_context) + .map_err(|e| format!("Error initializing platform adapter: {e}"))?; + } + #[cfg(not(target_os = "android"))] + { + let _ = env; + let _ = context; + } + Ok(()) +} + +fn jstring_to_string( + env: &mut JNIEnv<'_>, + value: &JString<'_>, +) -> Result { + env.get_string(value) + .map(|value| value.into()) + .map_err(|source| BridgeOperationError::Jni(source.to_string())) +} + +fn java_string_or_fallback<'local>( + env: &mut JNIEnv<'local>, + value: impl AsRef, +) -> JString<'local> { + env.new_string(value.as_ref()).unwrap_or_else(|_| { + env.new_string("JNI string allocation failed") + .expect("fallback string") + }) +} + +fn success_envelope(command: &str, data: Value) -> String { + json!({ + "ok": true, + "command": command, + "data": data, + "warnings": [], + }) + .to_string() +} + +fn operation_error_envelope(command: &str, error: BridgeOperationError) -> String { + let (code, message, detail) = error.parts(); + error_envelope(command, code, message, detail) +} + +fn error_envelope(command: &str, code: &str, message: &str, detail: Option) -> String { + json!({ + "ok": false, + "command": command, + "error": { + "code": code, + "message": message, + "detail": detail, + }, + }) + .to_string() +} + +#[derive(Debug, thiserror::Error)] +enum BridgeOperationError { + #[error("invalid bridge request: {0}")] + InvalidRequest(String), + #[error("JNI error: {0}")] + Jni(String), + #[error("bridge initialization failed: {0}")] + Initialize(String), + #[error("platform error: {0}")] + Platform(#[from] upgradeall_platform_adapter::PlatformAdapterError), + #[error("platform inventory response is malformed: {0}")] + PlatformMalformed(String), + #[error("storage error: {0}")] + Storage(String), + #[error("repository error: {0}")] + Repository(String), + #[error("autogen error: {0}")] + Autogen(String), + #[error("migration error: {0}")] + Migration(#[from] LegacyRoomOperationError), + #[error("read model error: {0}")] + ReadModel(#[from] ReadModelOperationError), + #[error("runtime error: {0}")] + Runtime(#[from] runtime_operations::RuntimeOperationError), + #[error("runtime lock is poisoned")] + RuntimePoisoned, + #[error("runtime notification queue is poisoned")] + RuntimeNotificationQueuePoisoned, +} + +impl BridgeOperationError { + fn parts(self) -> (&'static str, &'static str, Option) { + match self { + Self::InvalidRequest(detail) => ( + "bridge.invalid_request", + "Getter native bridge request is invalid", + Some(detail), + ), + Self::Jni(detail) => ( + "bridge.jni_error", + "Getter native bridge JNI operation failed", + Some(detail), + ), + Self::Initialize(detail) => ( + "bridge.initialize_error", + "Getter native bridge initialization failed", + Some(detail), + ), + Self::Platform(upgradeall_platform_adapter::PlatformAdapterError::Unsupported { + capability, + }) => ( + "platform.unsupported", + "Android platform capability is unsupported", + Some(capability.to_owned()), + ), + Self::Platform(upgradeall_platform_adapter::PlatformAdapterError::NotInitialized) => ( + "platform.not_initialized", + "Android platform adapter is not initialized", + None, + ), + Self::Platform(upgradeall_platform_adapter::PlatformAdapterError::Jni(detail)) => ( + "platform.jni_error", + "Android platform adapter JNI operation failed", + Some(detail), + ), + Self::Platform( + upgradeall_platform_adapter::PlatformAdapterError::MalformedResponse(detail), + ) => ( + "platform.malformed_response", + "Android platform adapter response is malformed", + Some(detail), + ), + Self::PlatformMalformed(detail) => ( + "platform.malformed_response", + "Android platform inventory response is malformed", + Some(detail), + ), + Self::Storage(detail) => ( + "storage.error", + "Getter storage operation failed", + Some(detail), + ), + Self::Repository(detail) => ( + "repository.error", + "Getter repository operation failed", + Some(detail), + ), + Self::Autogen(detail) => ( + "autogen.error", + "Getter autogen operation failed", + Some(detail), + ), + Self::Migration(error) => ( + error.code(), + error.message(), + error + .detail() + .or_else(|| error.report_path().map(|path| path.display().to_string())), + ), + Self::ReadModel(error) => (error.code(), error.message(), error.detail()), + Self::Runtime(error) => (error.code(), error.message(), error.detail()), + Self::RuntimePoisoned => ("runtime.poisoned", "Getter runtime lock is poisoned", None), + Self::RuntimeNotificationQueuePoisoned => ( + "runtime.notification_queue_poisoned", + "Getter runtime notification queue is poisoned", + None, + ), + } + } +} + +impl From for BridgeOperationError { + fn from(value: getter::storage::StorageError) -> Self { + Self::Storage(value.to_string()) + } +} + +impl From for BridgeOperationError { + fn from(value: AutogenOperationError) -> Self { + match value { + AutogenOperationError::Storage(source) => Self::Storage(source.to_string()), + AutogenOperationError::Repository(detail) => Self::Repository(detail), + AutogenOperationError::Autogen(detail) => Self::Autogen(detail), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use getter::core::{ + repository::{RepositoryMetadata, REPO_API_VERSION_V1}, + runtime::{PackageVersionLuaObject, SealedActionPlan}, + RepositoryPriority, UpdateAction, + }; + use std::fs; + + #[test] + fn packages_acceptance_defaults_to_all() { + let acceptance = ApplyInstalledAutogenAcceptance::default() + .into_autogen_acceptance() + .expect("acceptance"); + + assert!(matches!(acceptance, AutogenAcceptance::AcceptAll)); + } + + #[test] + fn packages_acceptance_preserves_getter_package_ids() { + let acceptance = ApplyInstalledAutogenAcceptance { + mode: Some("packages".to_owned()), + package_ids: vec!["android/org.fdroid.fdroid".parse().expect("package id")], + } + .into_autogen_acceptance() + .expect("acceptance"); + + match acceptance { + AutogenAcceptance::Accept(ids) => { + assert_eq!(ids[0].to_string(), "android/org.fdroid.fdroid") + } + AutogenAcceptance::AcceptAll => panic!("expected explicit package acceptance"), + } + } + + #[test] + fn read_operation_lists_repositories_and_evaluates_packages() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let repo_root = temp.path().join("repo"); + write_static_update_repo(&repo_root); + let db = open_main_db(&data_dir).unwrap(); + db.upsert_repository( + &RepositoryMetadata { + id: "official".parse().unwrap(), + name: "Official".to_owned(), + priority: RepositoryPriority::new(0), + api_version: REPO_API_VERSION_V1.to_owned(), + }, + Some(&repo_root), + None, + ) + .unwrap(); + + let repositories = read_operation( + json!({ + "operation": "repository_list", + "data_dir": data_dir, + }) + .to_string(), + ) + .expect("repository list"); + assert_eq!(repositories["repositories"][0]["id"], "official"); + + let package = read_operation( + json!({ + "operation": "package_eval", + "data_dir": data_dir, + "payload": { "package_id": "android/org.fdroid.fdroid" } + }) + .to_string(), + ) + .expect("package eval"); + assert_eq!(package["package"]["id"], "android/org.fdroid.fdroid"); + assert_eq!(package["package"]["repository"], "official"); + } + + #[test] + fn runtime_dispatcher_issues_action_from_registered_package_update_check() { + let temp = tempfile::tempdir().unwrap(); + let data_dir = temp.path().join("data"); + let repo_root = temp.path().join("repo"); + write_static_update_repo(&repo_root); + let db = open_main_db(&data_dir).unwrap(); + db.upsert_repository( + &RepositoryMetadata { + id: "official".parse().unwrap(), + name: "Official".to_owned(), + priority: RepositoryPriority::new(0), + api_version: REPO_API_VERSION_V1.to_owned(), + }, + Some(&repo_root), + None, + ) + .unwrap(); + let mut runtime = getter::core::runtime::GetterRuntime::new(); + + let issued = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "update_check_package_issue_action", + "data_dir": data_dir, + "payload": { + "package_id": "android/org.fdroid.fdroid", + "installed_version": "1.0.0" + } + }) + .to_string(), + ) + .expect("issue action"); + + assert_eq!(issued["package"]["repository"], "official"); + assert_eq!(issued["update"]["status"], "update_available"); + assert!(issued["action"]["action_id"].as_str().is_some()); + } + + #[test] + fn runtime_dispatcher_issues_action_from_offline_update_check() { + let mut runtime = getter::core::runtime::GetterRuntime::new(); + + let issued = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "update_check_offline_issue_action", + "payload": { + "fixture": { + "format": "getter-offline-update-check", + "version": 1, + "package_id": "android/org.fdroid.fdroid", + "installed_version": "1.0.0", + "candidates": [ + { + "version": "1.2.0", + "artifacts": [ + { + "name": "app.apk", + "url": "https://example.invalid/app.apk", + "file_name": "app.apk" + } + ] + } + ] + } + } + }) + .to_string(), + ) + .expect("issue action"); + + assert_eq!(issued["update"]["status"], "update_available"); + let action_id = issued["action"]["action_id"].as_str().expect("action id"); + let submitted = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "task_submit", + "payload": { "action_id": action_id } + }) + .to_string(), + ) + .expect("submit issued action"); + assert_eq!(submitted["package_id"], "android/org.fdroid.fdroid"); + } + + #[test] + fn runtime_dispatcher_uses_in_memory_runtime_controls() { + let mut runtime = getter::core::runtime::GetterRuntime::new(); + let action = runtime_operations::issue_action( + &mut runtime, + SealedActionPlan { + package_id: "android/org.fdroid.fdroid".parse().expect("package id"), + actions: vec![ + UpdateAction::Download { + url: "https://example.invalid/app.apk".to_owned(), + file_name: "app.apk".to_owned(), + }, + UpdateAction::Install { + installer: "android_package".to_owned(), + file: "app.apk".to_owned(), + }, + ], + lua_object: PackageVersionLuaObject { + object_id: "lua:android/org.fdroid.fdroid".to_owned(), + dependency_digest: "sha256:test".to_owned(), + }, + }, + ); + let action_id = action["action_id"].as_str().expect("action id"); + + let submitted = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "task_submit", + "payload": { "action_id": action_id } + }) + .to_string(), + ) + .expect("submit"); + let task_id = submitted["task_id"].as_str().expect("task id"); + assert_eq!(submitted["status"], "queued"); + + runtime_operation_with_runtime( + &mut runtime, + &json!({ "operation": "task_start", "payload": { "task_id": task_id } }).to_string(), + ) + .expect("start"); + let waiting = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "task_complete_download", + "payload": { "task_id": task_id } + }) + .to_string(), + ) + .expect("complete download"); + assert_eq!(waiting["status"], "running"); + assert_eq!(waiting["phase"]["category"], "waiting_user"); + + let completed = runtime_operation_with_runtime( + &mut runtime, + &json!({ + "operation": "task_user_result", + "payload": { "task_id": task_id, "result": "accepted" } + }) + .to_string(), + ) + .expect("user result"); + assert_eq!(completed["status"], "completed"); + } + + #[test] + fn runtime_dispatcher_rejects_unknown_operation() { + let mut runtime = getter::core::runtime::GetterRuntime::new(); + + let error = runtime_operation_with_runtime( + &mut runtime, + &json!({ "operation": "task_install_result", "payload": {} }).to_string(), + ) + .unwrap_err(); + + let (code, _, detail) = error.parts(); + assert_eq!(code, "runtime.invalid_request"); + assert!(detail.unwrap().contains("unsupported runtime operation")); + } + + fn write_static_update_repo(root: &std::path::Path) { + fs::create_dir_all(root.join("packages/android")).unwrap(); + fs::create_dir(root.join("lib")).unwrap(); + fs::create_dir(root.join("templates")).unwrap(); + fs::write( + root.join("repo.toml"), + r#"id = "official" +name = "Official" +priority = 0 +api_version = "getter.repo.v1" +"#, + ) + .unwrap(); + fs::write( + root.join("packages/android/org.fdroid.fdroid.lua"), + r#" +return package_def { + id = "android/org.fdroid.fdroid", + name = "F-Droid", + updates = { + { + version = "1.2.0", + artifacts = { + { + name = "app.apk", + url = "https://example.invalid/app.apk", + file_name = "app.apk", + }, + }, + }, + }, +} +"#, + ) + .unwrap(); + } + + #[test] + fn runtime_notification_queue_is_bounded_and_drained() { + drain_runtime_notifications().expect("clear queue"); + for index in 0..(MAX_RUNTIME_NOTIFICATION_QUEUE + 1) { + enqueue_runtime_notification(getter::core::runtime::RuntimeNotification::TaskChanged { + task: getter::core::runtime::TaskSnapshot { + task_id: format!("task-{index}"), + package_id: "android/org.fdroid.fdroid".parse().expect("package id"), + status: getter::core::runtime::RuntimeTaskStatus::Running, + phase: getter::core::runtime::TaskPhase::new( + getter::core::runtime::TaskPhaseCategory::Download, + ), + progress: None, + capabilities: getter::core::runtime::TaskCapabilities::default(), + current_diagnostic: None, + updated_at: index as u64, + }, + }); + } + + let drained = drain_runtime_notifications().expect("drain notifications"); + let notifications = drained["notifications"].as_array().expect("notifications"); + + assert_eq!(notifications.len(), MAX_RUNTIME_NOTIFICATION_QUEUE); + assert_eq!(notifications[0]["task"]["task_id"], "task-1"); + assert_eq!(notifications.last().unwrap()["task"]["task_id"], "task-64"); + let empty = drain_runtime_notifications().expect("drain empty queue"); + assert_eq!(empty["notifications"].as_array().unwrap().len(), 0); } } diff --git a/core-getter/src/main/rust/getter b/core-getter/src/main/rust/getter index f011d9b4b..60a65158a 160000 --- a/core-getter/src/main/rust/getter +++ b/core-getter/src/main/rust/getter @@ -1 +1 @@ -Subproject commit f011d9b4b9a15f83cd39c86e781ad8830a8ecae6 +Subproject commit 60a65158aed40e14f0dea17427b4a92ec1e43818 diff --git a/core-getter/src/main/rust/platform_adapter/Cargo.toml b/core-getter/src/main/rust/platform_adapter/Cargo.toml new file mode 100644 index 000000000..c0f68a100 --- /dev/null +++ b/core-getter/src/main/rust/platform_adapter/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "upgradeall-platform-adapter" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +once_cell = "1" + +[dev-dependencies] +getter-core = { path = "../getter/crates/getter-core" } diff --git a/core-getter/src/main/rust/platform_adapter/src/android.rs b/core-getter/src/main/rust/platform_adapter/src/android.rs new file mode 100644 index 000000000..acd211d91 --- /dev/null +++ b/core-getter/src/main/rust/platform_adapter/src/android.rs @@ -0,0 +1,174 @@ +//! Android runtime plumbing for Rust-active platform calls. +//! +//! This follows the same shape as rustls-platform-verifier: the Rust native +//! entrypoint initializes JVM/context/classloader handles once, then Rust code +//! can attach a thread and call app classes through the app classloader. + +use crate::{ + InstalledInventoryScanOptions, InstalledInventoryScanResult, PlatformAdapter, + PlatformAdapterError, +}; +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; +use jni::{JNIEnv, JavaVM}; +use once_cell::sync::OnceCell; + +static RUNTIME: OnceCell = OnceCell::new(); +const INSTALLED_INVENTORY_PROVIDER_CLASS: &str = + "net.xzos.upgradeall.getter.platform.InstalledInventoryProvider"; + +struct AndroidRuntime { + java_vm: JavaVM, + application_context: GlobalRef, + class_loader: GlobalRef, +} + +/// Initialize Android platform access from a JNI entrypoint. +/// +/// `context` should be an Android `Context`. The function stores +/// `context.getApplicationContext()` and its class loader as global refs. It is +/// idempotent for the lifetime of the process. +pub fn init_with_env( + env: &mut JNIEnv<'_>, + context: JObject<'_>, +) -> Result<(), PlatformAdapterError> { + RUNTIME + .get_or_try_init(|| runtime_from_env(env, context)) + .map(|_| ()) +} + +fn runtime_from_env( + env: &mut JNIEnv<'_>, + context: JObject<'_>, +) -> Result { + let java_vm = env + .get_java_vm() + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + + let application_context = env + .call_method( + &context, + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .and_then(|value| value.l()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let application_context = if application_context.is_null() { + env.new_global_ref(&context) + } else { + env.new_global_ref(&application_context) + } + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + + let class_loader = env + .call_method( + application_context.as_obj(), + "getClassLoader", + "()Ljava/lang/ClassLoader;", + &[], + ) + .and_then(|value| value.l()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let class_loader = env + .new_global_ref(&class_loader) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + + Ok(AndroidRuntime { + java_vm, + application_context, + class_loader, + }) +} + +/// Android implementation placeholder for platform capabilities. +#[derive(Debug, Default)] +pub struct AndroidPlatformAdapter; + +impl PlatformAdapter for AndroidPlatformAdapter { + fn scan_installed_inventory( + &self, + options: InstalledInventoryScanOptions, + ) -> Result { + let options_json = serde_json::to_string(&options).map_err(|error| { + PlatformAdapterError::MalformedResponse(format!( + "failed to encode scan options for Android provider: {error}" + )) + })?; + + with_attached_env(|env, runtime| { + let provider_class = JClass::from(load_class( + env, + runtime, + INSTALLED_INVENTORY_PROVIDER_CLASS, + )?); + let context = env + .new_local_ref(runtime.application_context.as_obj()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let options_json = env + .new_string(options_json) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let options_json = JObject::from(options_json); + + let result = env + .call_static_method( + provider_class, + "scanInstalledInventory", + "(Landroid/content/Context;Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(&context), JValue::Object(&options_json)], + ) + .and_then(|value| value.l()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let result_json = java_string(env, result)?; + + serde_json::from_str(&result_json).map_err(|error| { + PlatformAdapterError::MalformedResponse(format!( + "Android installed inventory provider returned invalid JSON: {error}" + )) + }) + }) + } +} + +fn with_attached_env( + f: impl FnOnce(&mut JNIEnv<'_>, &AndroidRuntime) -> Result, +) -> Result { + let runtime = RUNTIME.get().ok_or(PlatformAdapterError::NotInitialized)?; + let mut env = runtime + .java_vm + .attach_current_thread() + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + + f(&mut env, runtime) +} + +/// Load an application class with the app classloader instead of `FindClass`. +fn load_class<'local>( + env: &mut JNIEnv<'local>, + runtime: &AndroidRuntime, + binary_name: &str, +) -> Result, PlatformAdapterError> { + let name = env + .new_string(binary_name) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + let class = env + .call_method( + runtime.class_loader.as_obj(), + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[jni::objects::JValue::Object(&JObject::from(name))], + ) + .and_then(|value| value.l()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string()))?; + Ok(class) +} + +/// Convert a Java string into a Rust string. +fn java_string(env: &mut JNIEnv<'_>, value: JObject<'_>) -> Result { + if value.is_null() { + return Ok(String::new()); + } + let value = JString::from(value); + env.get_string(&value) + .map(|value| value.into()) + .map_err(|error| PlatformAdapterError::Jni(error.to_string())) +} diff --git a/core-getter/src/main/rust/platform_adapter/src/lib.rs b/core-getter/src/main/rust/platform_adapter/src/lib.rs new file mode 100644 index 000000000..9c1463317 --- /dev/null +++ b/core-getter/src/main/rust/platform_adapter/src/lib.rs @@ -0,0 +1,227 @@ +//! Rust-active platform capability adapter for the UpgradeAll Android product. +//! +//! This crate intentionally lives outside the reusable getter submodule. It +//! defines platform facts and Android runtime plumbing for the product/native +//! bridge layer. getter still owns domain decisions such as package ids, +//! repository coverage, Lua generation, and storage writes. + +use serde::{Deserialize, Serialize}; + +#[cfg(target_os = "android")] +pub mod android; + +pub const INSTALLED_INVENTORY_FORMAT: &str = "upgradeall-installed-inventory"; +pub const INSTALLED_INVENTORY_VERSION: u32 = 1; + +/// A small Rust-owned interface for platform capabilities. +/// +/// Implementations return platform facts only. Callers must not infer getter +/// product decisions from this interface; the native bridge/getter operation is +/// responsible for converting facts into getter-owned workflows. +pub trait PlatformAdapter: Send + Sync { + fn scan_installed_inventory( + &self, + options: InstalledInventoryScanOptions, + ) -> Result; +} + +/// Host/test adapter used when no platform implementation is available. +#[derive(Debug, Default)] +pub struct NoopPlatformAdapter; + +impl PlatformAdapter for NoopPlatformAdapter { + fn scan_installed_inventory( + &self, + _options: InstalledInventoryScanOptions, + ) -> Result { + Err(PlatformAdapterError::Unsupported { + capability: "installed_inventory", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledInventoryScanOptions { + #[serde(default)] + pub include_system_apps: bool, + #[serde(default)] + pub include_self: bool, +} + +impl Default for InstalledInventoryScanOptions { + fn default() -> Self { + Self { + include_system_apps: false, + include_self: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledInventoryScanResult { + pub inventory: InstalledInventory, + pub stats: InstalledInventoryScanStats, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledInventory { + pub format: String, + pub version: u32, + #[serde(default)] + pub items: Vec, +} + +impl InstalledInventory { + pub fn new(items: Vec) -> Self { + Self { + format: INSTALLED_INVENTORY_FORMAT.to_owned(), + version: INSTALLED_INVENTORY_VERSION, + items, + } + } +} + +/// Getter-compatible installed inventory facts produced by platform code. +/// +/// Android platform adapters emit raw package names and metadata only. They do +/// not normalize to `android/` package ids. Magisk facts are excluded +/// from this PackageManager adapter surface and need a separate capability +/// decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InstalledInventoryItem { + AndroidPackage { + package_name: String, + #[serde(default)] + label: Option, + #[serde(default)] + version_name: Option, + #[serde(default)] + version_code: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledInventoryScanStats { + pub total_seen: u32, + pub returned: u32, + pub filtered_system: u32, + pub filtered_self: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlatformDiagnostic { + pub code: String, + pub message: String, + #[serde(default)] + pub detail: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum PlatformAdapterError { + #[error("platform capability '{capability}' is unsupported")] + Unsupported { capability: &'static str }, + #[error("platform adapter is not initialized")] + NotInitialized, + #[error("platform adapter JNI error: {0}")] + Jni(String), + #[error("platform adapter response is malformed: {0}")] + MalformedResponse(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scan_options_default_to_privacy_preserving_user_inventory() { + let options = InstalledInventoryScanOptions::default(); + + assert!(!options.include_system_apps); + assert!(!options.include_self); + + let json = serde_json::to_value(options).expect("serialize options"); + assert_eq!(json["include_system_apps"], false); + assert_eq!(json["include_self"], false); + } + + #[test] + fn inventory_serializes_to_getter_compatible_android_package_facts() { + let inventory = InstalledInventory::new(vec![InstalledInventoryItem::AndroidPackage { + package_name: "org.fdroid.fdroid".to_owned(), + label: Some("F-Droid".to_owned()), + version_name: Some("1.20.0".to_owned()), + version_code: Some(1_020_000), + }]); + + let json = serde_json::to_value(&inventory).expect("serialize inventory"); + + assert_eq!(json["format"], INSTALLED_INVENTORY_FORMAT); + assert_eq!(json["version"], INSTALLED_INVENTORY_VERSION); + assert_eq!(json["items"][0]["kind"], "android_package"); + assert_eq!(json["items"][0]["package_name"], "org.fdroid.fdroid"); + assert!(json["items"][0].get("package_id").is_none()); + } + + #[test] + fn scan_result_deserializes_with_default_diagnostics() { + let json = r#" + { + "inventory": { + "format": "upgradeall-installed-inventory", + "version": 1, + "items": [] + }, + "stats": { + "total_seen": 3, + "returned": 1, + "filtered_system": 1, + "filtered_self": 1 + } + } + "#; + + let result: InstalledInventoryScanResult = + serde_json::from_str(json).expect("deserialize scan result"); + + assert!(result.diagnostics.is_empty()); + assert_eq!(result.stats.total_seen, 3); + assert_eq!(result.inventory.items, Vec::new()); + } + + #[test] + fn noop_adapter_reports_unsupported_installed_inventory() { + let adapter = NoopPlatformAdapter; + + let error = adapter + .scan_installed_inventory(InstalledInventoryScanOptions::default()) + .expect_err("noop adapter should not scan"); + + assert!(matches!( + error, + PlatformAdapterError::Unsupported { + capability: "installed_inventory" + } + )); + } + + #[test] + fn platform_inventory_json_is_accepted_by_getter_core_autogen_schema() { + let inventory = InstalledInventory::new(vec![InstalledInventoryItem::AndroidPackage { + package_name: "org.fdroid.fdroid".to_owned(), + label: Some("F-Droid".to_owned()), + version_name: Some("1.20.0".to_owned()), + version_code: Some(1_020_000), + }]); + let json = serde_json::to_string(&inventory).expect("serialize platform inventory"); + + let getter_inventory: getter_core::autogen::InstalledInventory = + serde_json::from_str(&json).expect("getter-core should accept platform inventory"); + + getter_core::autogen::validate_installed_inventory(&getter_inventory) + .expect("inventory format/version should match getter core"); + assert_eq!(getter_inventory.items.len(), 1); + } +} diff --git a/core-getter/src/test/java/net/xzos/upgradeall/getter/platform/InstalledInventoryCollectorTest.kt b/core-getter/src/test/java/net/xzos/upgradeall/getter/platform/InstalledInventoryCollectorTest.kt new file mode 100644 index 000000000..c4c6757d9 --- /dev/null +++ b/core-getter/src/test/java/net/xzos/upgradeall/getter/platform/InstalledInventoryCollectorTest.kt @@ -0,0 +1,110 @@ +package net.xzos.upgradeall.getter.platform + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstalledInventoryCollectorTest { + @Test + fun defaultOptionsFilterSelfAndSystemPackagesAndSortResults() { + val result = InstalledInventoryCollector.collect( + selfPackageName = "net.xzos.upgradeall", + packages = listOf( + rawPackage("org.fdroid.fdroid", label = "F-Droid"), + rawPackage("android", label = "Android System", isSystem = true), + rawPackage("net.xzos.upgradeall", label = "UpgradeAll"), + rawPackage("com.termux", label = "Termux"), + ), + options = InstalledInventoryScanOptions(), + ) + + assertEquals(4, result.stats.totalSeen) + assertEquals(2, result.stats.returned) + assertEquals(1, result.stats.filteredSystem) + assertEquals(1, result.stats.filteredSelf) + assertEquals(listOf("com.termux", "org.fdroid.fdroid"), result.inventory.items.map { it.packageName }) + assertFalse(result.inventory.items.any { it.packageName.startsWith("android/") }) + } + + @Test + fun optionsCanIncludeSelfAndSystemPackages() { + val result = InstalledInventoryCollector.collect( + selfPackageName = "net.xzos.upgradeall", + packages = listOf( + rawPackage("android", isSystem = true), + rawPackage("net.xzos.upgradeall"), + ), + options = InstalledInventoryScanOptions( + includeSystemApps = true, + includeSelf = true, + ), + ) + + assertEquals(2, result.stats.totalSeen) + assertEquals(2, result.stats.returned) + assertEquals(0, result.stats.filteredSystem) + assertEquals(0, result.stats.filteredSelf) + assertEquals(listOf("android", "net.xzos.upgradeall"), result.inventory.items.map { it.packageName }) + } + + @Test + fun duplicatePackageNamesKeepTheLastFactDeterministically() { + val result = InstalledInventoryCollector.collect( + selfPackageName = "net.xzos.upgradeall", + packages = listOf( + rawPackage("org.fdroid.fdroid", label = "Old Label", versionCode = 1), + rawPackage("org.fdroid.fdroid", label = "New Label", versionCode = 2), + ), + options = InstalledInventoryScanOptions(), + ) + + assertEquals(2, result.stats.totalSeen) + assertEquals(1, result.stats.returned) + assertEquals("New Label", result.inventory.items.single().label) + assertEquals(2L, result.inventory.items.single().versionCode) + } + + @Test + fun blankPackageNamesAreSkippedWithoutCreatingPackageIds() { + val result = InstalledInventoryCollector.collect( + selfPackageName = "net.xzos.upgradeall", + packages = listOf( + rawPackage(" ", label = "Blank"), + rawPackage("com.example.valid", label = "Valid"), + ), + options = InstalledInventoryScanOptions(), + ) + + assertEquals(2, result.stats.totalSeen) + assertEquals(1, result.stats.returned) + assertEquals("com.example.valid", result.inventory.items.single().packageName) + } + + @Test + fun inventoryContractMatchesGetterInstalledInventoryFormat() { + val result = InstalledInventoryCollector.collect( + selfPackageName = "net.xzos.upgradeall", + packages = listOf(rawPackage("org.fdroid.fdroid")), + options = InstalledInventoryScanOptions(), + ) + + assertEquals("upgradeall-installed-inventory", result.inventory.format) + assertEquals(1, result.inventory.version) + assertTrue(result.diagnostics.isEmpty()) + } + + private fun rawPackage( + packageName: String, + label: String? = null, + versionName: String? = null, + versionCode: Long? = null, + isSystem: Boolean = false, + ) = RawInstalledPackage( + packageName = packageName, + label = label, + versionName = versionName, + versionCode = versionCode, + isSystem = isSystem, + ) +} diff --git a/core-websdk/data/build.gradle.kts b/core-websdk/data/build.gradle.kts index 0ee8d3dc0..1d00a1864 100644 --- a/core-websdk/data/build.gradle.kts +++ b/core-websdk/data/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("java-library") alias(libs.plugins.kotlin.jvm) @@ -8,6 +10,12 @@ java { targetCompatibility = JavaVersion.VERSION_21 } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + dependencies { implementation(libs.gson) implementation(libs.jackson.databind) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..235982628 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# UpgradeAll Rewrite Documentation + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +This documentation set records the design decisions for the UpgradeAll rewrite. It exists so coding agents and human maintainers can trace every major implementation choice back to a written decision. + +## Toolchain baseline + +The rewrite should be validated on current stable toolchains, not old local SDKs: + +- Flutter stable `>=3.44.4` with Dart `>=3.12.2 <4.0.0`. +- Rust stable; latest local validated baseline is `rustc 1.96.0` / `cargo 1.96.0`. +- Android Gradle Plugin `9.0.1`, Gradle `9.3.1`, Kotlin Gradle Plugin `2.3.20`. +- Android product APK `minSdkVersion` follows the active stable Flutter SDK's `flutter.minSdkVersion`. + +Start here: + +1. `architecture/upgradeall-getter-rewrite-wiki.md` — main living architecture wiki. +2. `architecture/adr/0001-app-centric-lua-package-repository-model.md` — package/repository/Lua model. +3. `architecture/adr/0002-getter-flutter-platform-boundary.md` — getter vs Flutter/platform adapter boundary. +4. `architecture/adr/0003-legacy-room-migration.md` — old Room DB migration strategy. +5. `architecture/adr/0004-sqlite-main-db-and-cache-db.md` — storage and cache split. +6. `architecture/adr/0005-lua-package-api.md` — Lua package API and Rust validation boundary. +7. `architecture/adr/0006-package-centric-cli-command-contract.md` — getter CLI automation contract. +8. `architecture/adr/0007-flutter-getter-bridge-contract.md` — Flutter/getter DTO and bridge contract. +9. `architecture/adr/0008-flutter-product-apk-entry.md` — Flutter app as the sole product APK entry. +10. `architecture/adr/0009-android-platform-adapter-and-package-visibility.md` — Rust-active Android platform adapter and package visibility policy. +11. `architecture/adr/0010-package-metadata-cache-and-version-baseline.md` — accepted package metadata cache, live-version, installed-version, and `pin_version` rules. +12. `architecture/adr/0011-lua-update-runtime-side-effects-and-events.md` — accepted Phase D Lua runtime, task/action lifecycle, mock side-effect executor, and RuntimeNotification bridge rules. +13. `lua-api/` — practical Lua package authoring docs, including offline `repo validate` diagnostics. +14. `migration/legacy-room-mapping.md` — old data mapping rules. +15. `app/flutter-ui-feature-parity-and-testing.md` — Flutter feature parity and BDD/TDD test boundary. +16. `implementation/coding-agent-handoff.md` — coding-agent / pi-agent handoff instructions. + +Canonical architecture ADRs live in `docs/architecture/adr/*`. The `docs/adr/*` directory is kept for historical/refactor-phase ADRs and transition notes. + +Documentation policy: + +- Every major decision must be captured in the wiki or an ADR. +- Every cross-boundary API must have schema documentation before implementation stabilizes. +- Every migration must have source/target mapping and failure behavior documented. +- Every coding agent must read `../AGENTS.md` and this docs index before implementation. diff --git a/docs/adr/0001-flutter-shell-rust-core.md b/docs/adr/0001-flutter-shell-rust-core.md new file mode 100644 index 000000000..9fa73f013 --- /dev/null +++ b/docs/adr/0001-flutter-shell-rust-core.md @@ -0,0 +1,28 @@ +# 0001: Flutter shell with getter-owned product logic + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +UpgradeAll is currently an Android/Kotlin multi-module application with a Rust `getter` submodule already integrated through native Android build tooling. The 2026-06-20 rewrite plan chooses Flutter for the new app shell and moves durable product logic into `getter`. + +The key trade-off is whether the application remains Android/Kotlin-centered or becomes a thin cross-platform shell around a reusable headless engine. + +## Decision + +The rewritten UpgradeAll App will be a Flutter UI/platform shell. `getter` is the headless product engine and owns durable product behavior: source interpretation, update checks, release discovery, download orchestration, provider/downloader registration, storage, migrations, and event streams. + +Flutter must not grow a second copy of getter product logic. UI code may adapt presentation, navigation, platform permissions, and source-level pages, but product decisions must flow through getter contracts. + +## Consequences + +- The app can become cross-platform without duplicating update logic per UI host. +- Getter contracts must be intentionally designed, versioned, documented, and tested. +- UI work cannot start by drawing screens around mock logic; it must be driven by getter-facing behavior scenarios and DTO contracts. +- Android compatibility work remains important because existing installed users must migrate safely. + +## Alternatives considered + +- Keep Android/Kotlin as the product center and call Rust only for selected helpers. This preserves current shape but keeps logic split across platform code and makes Flutter a risky rewrite. +- Make Flutter own product logic and use getter only as a library of utilities. This weakens the reusable engine goal and makes CLI/library support secondary. diff --git a/docs/adr/0002-rust-sqlite-storage.md b/docs/adr/0002-rust-sqlite-storage.md new file mode 100644 index 000000000..da7e12107 --- /dev/null +++ b/docs/adr/0002-rust-sqlite-storage.md @@ -0,0 +1,24 @@ +# 0002: Rust-managed SQLite storage + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +The rewrite needs a durable storage model that is owned by the headless engine rather than by a specific UI host. The 2026-06-20 plan rejects ad-hoc JSONL as the long-term store and requires a tested migration path from legacy Android Room data. + +## Decision + +Getter will own the new canonical SQLite storage. Legacy Android Room is a migration source, not the long-term source of truth. JSON/JSONL may exist only as import/export, diagnostics, fixtures, or alpha compatibility data, not as the official durable store for the rewritten product. + +## Consequences + +- Storage migrations can be tested at the getter layer without a UI. +- Flutter, CLI, and other hosts share the same durable model. +- A legacy import path must preserve supported existing Android data before the official Flutter Android release. +- Storage schema and canonical ID rules require tests before implementation changes. + +## Alternatives considered + +- Keep Room as the primary store. This preserves existing Android implementation but conflicts with a reusable getter engine. +- Keep JSONL initially and migrate later. This reduces early work but creates a second migration and risks shipping unstable persistence semantics. diff --git a/docs/adr/0003-source-level-page-customization.md b/docs/adr/0003-source-level-page-customization.md new file mode 100644 index 000000000..d0f149688 --- /dev/null +++ b/docs/adr/0003-source-level-page-customization.md @@ -0,0 +1,24 @@ +# 0003: Source-level page customization + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +UpgradeAll users may want customized pages and flows. Runtime UI plugins would increase app complexity, safety risk, test surface, and compatibility burden. The rewrite plan instead emphasizes source-level downstream customization. + +## Decision + +UpgradeAll will support page customization through source-level modules and typed contracts, not through a v1 runtime UI plugin system. Upstream should provide stable page contracts, default pages, examples, and compile/test failures when custom pages drift from contracts. + +## Consequences + +- Downstream builders can fork, modify pages, run tests, and rebuild. +- Runtime app complexity stays lower than a plugin UI framework. +- Stable route IDs, semantic/test IDs, and page contracts become product requirements. +- Upstream should avoid needless churn in customization surfaces. + +## Alternatives considered + +- Runtime UI plugins. More flexible for installed apps, but much harder to secure, test, and keep compatible during the rewrite. +- No customization boundary. Simpler initially, but conflicts with the selected distribution philosophy. diff --git a/docs/adr/0004-legacy-room-migration.md b/docs/adr/0004-legacy-room-migration.md new file mode 100644 index 000000000..80e1259d8 --- /dev/null +++ b/docs/adr/0004-legacy-room-migration.md @@ -0,0 +1,26 @@ +# 0004: First-class legacy Room migration + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +Existing Android users have data in the legacy UpgradeAll Room database. The official Android upgrade path must preserve package identity and user data. Migration failure must be visible and recoverable rather than silently destructive. + +## Decision + +Legacy Android Room migration is a first-class compatibility subsystem. The official Flutter Android upgrade must keep the existing application identity and use a tested import flow from supported legacy Room schemas into getter-owned storage. + +Migration must be transactional from the user's perspective: a failure must not leave a partially usable new app state. The app must provide recovery actions such as retry, report export, and explicit start-fresh confirmation. + +## Consequences + +- The project needs migration fixtures and end-to-end migration tests before release. +- Legacy schema support boundaries must be explicit. +- The legacy migrator can be removed only after a separately documented support decision. +- Android signing/package identity is part of the migration contract. + +## Alternatives considered + +- Best-effort startup migration. Easier to implement but risky for user data. +- Manual export/import only. Avoids direct migration complexity but breaks the official upgrade expectation. diff --git a/docs/adr/0005-tdd-bdd-cucumber-policy.md b/docs/adr/0005-tdd-bdd-cucumber-policy.md new file mode 100644 index 000000000..5f088a250 --- /dev/null +++ b/docs/adr/0005-tdd-bdd-cucumber-policy.md @@ -0,0 +1,35 @@ +# 0005: TDD and Cucumber behavior coverage policy + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +The refactor must be test-driven. The user clarified that Cucumber/Gherkin BDD is required for user-facing behavior, especially the UpgradeAll App and Getter CLI. BDD should cover integration-level behavior, while internal algorithms and module boundaries should keep faster traditional tests. + +Cucumber documentation defines behavior specs as Gherkin `Feature`, `Scenario`, `Given`, `When`, and `Then` files with tags, data tables, and scenario outlines. Cucumber step definitions bind those phrases to executable code. The Rust Cucumber implementation uses `.feature` files, a per-scenario `World`, and async step functions. + +## Decision + +Every behavior-changing implementation must start from a failing automated test. + +Cucumber/Gherkin is mandatory for supported user-facing interfaces: + +- UpgradeAll App workflows. +- Getter CLI commands, output contracts, errors, and exit codes. +- User-visible migration success and recovery behavior. +- Cross-boundary acceptance behavior where a user action depends on getter outcomes. + +Internal interfaces do not require Gherkin unless promoted to supported user-facing contracts. They should use the fastest appropriate traditional tests: Rust unit/integration/property tests, storage migration tests, Kotlin/Dart unit tests, widget tests, and focused integration tests. + +## Consequences + +- BDD scenarios become acceptance contracts, not a replacement for all unit tests. +- Getter CLI must be designed before implementation because its behavior scenarios need stable commands, JSON/human output rules, and exit-code semantics. +- UI screens must expose stable test IDs so scenarios do not depend on localized text. +- CI/verification must separate fast internal tests from slower BDD acceptance tests while keeping both required before release. + +## Alternatives considered + +- Require Gherkin for every test. This maximizes uniformity but slows feedback and makes low-level Rust/Kotlin/Dart tests verbose. +- Use only native unit/integration tests. Faster initially, but fails the requirement that user-facing behavior be expressed as executable behavior specs. diff --git a/docs/adr/0006-getter-library-and-cli.md b/docs/adr/0006-getter-library-and-cli.md new file mode 100644 index 000000000..c6edf3803 --- /dev/null +++ b/docs/adr/0006-getter-library-and-cli.md @@ -0,0 +1,32 @@ +# 0006: Getter as both library and CLI + +- Date: 2026-06-20 +- Status: Accepted for the refactor plan + +## Context + +Getter must serve multiple hosts. The UpgradeAll App needs an embeddable engine, while AI/operator workflows need a scriptable command-line surface. The current Rust crate already has a library entrypoint and a placeholder binary, but that does not define a supported library or CLI contract. + +## Decision + +Getter will be both: + +1. A library: the stable embeddable engine surface for UI hosts and integration adapters. +2. A CLI: the supported command-line user interface for verification, automation, diagnostics, and developer workflows. + +The CLI is a user-facing interface and therefore requires complete Cucumber/Gherkin coverage for supported commands. The library requires traditional unit/integration tests for internal behavior and contract tests where exposed to supported hosts. + +The CLI must not become an unrelated second implementation. It should call the same getter core behavior as the library. + +## Consequences + +- CLI command shape, output mode, error model, and exit codes need explicit design before implementation. +- Behavior scenarios for CLI can drive core workflow design without needing Flutter first. +- The library/CLI split helps prevent UI code from becoming the only way to exercise product behavior. +- Public module visibility must be distinguished from supported API contract. + +## Alternatives considered + +- Library only. Simpler, but weaker for AI/operator workflows and headless verification. +- CLI only. Useful for automation, but not sufficient for embedding in the app. +- Separate CLI logic. Faster to prototype but risks drift from app behavior. diff --git a/docs/adr/0007-getter-cli-command-contract.md b/docs/adr/0007-getter-cli-command-contract.md new file mode 100644 index 000000000..f254d81d9 --- /dev/null +++ b/docs/adr/0007-getter-cli-command-contract.md @@ -0,0 +1,90 @@ +# 0007: Getter CLI command contract + +- Date: 2026-06-20 +- Status: Accepted for the Phase 1a CLI contract + +## Context + +Getter CLI is a user-facing interface. Once Cucumber/Gherkin scenarios and step assertions are written, command names, output schemas, error schemas, side effects, and exit codes become supported behavior. ADR 0006 says the CLI needs explicit design before implementation. + +The canonical 06-20 plan gives examples such as `getter app list`, `getter hub list`, and `getter legacy import-room-bundle `. The refactor plan is AI/operator/CLI-first, so machine-readable output should be stable from the first slice. Phase 1a implemented this contract in the committed BDD-backed CLI spine. + +## Decision + +Getter CLI uses domain-noun subcommands and machine-readable JSON by default during the rewrite. + +Initial supported command grammar: + +```text +getter --data-dir init +getter --data-dir app list +getter --data-dir hub list +getter --data-dir legacy import-room-bundle +``` + +Global conventions: + +- `--data-dir ` is mandatory in early development and all BDD scenarios. +- JSON is the default output for supported commands. +- Human-readable output can be added later behind an explicit flag, but is not the first automation contract. +- Success payloads go to stdout. +- Error envelopes go to stdout when the command can run far enough to emit structured JSON; invalid CLI usage may use stderr/help text. +- Unstructured diagnostics must not be mixed into JSON stdout. + +Success envelope shape: + +```json +{ + "ok": true, + "command": "app list", + "data": {}, + "warnings": [] +} +``` + +Error envelope shape: + +```json +{ + "ok": false, + "command": "legacy import-room-bundle", + "error": { + "code": "migration.invalid_bundle", + "message": "Legacy Room export bundle is invalid", + "report_path": "/path/to/report.json" + } +} +``` + +Initial exit-code classes: + +- `0`: success. +- `1`: generic failure not covered by a more specific class. +- `2`: invalid CLI usage. +- `10`: data/storage error. +- `20`: migration/import error. +- `30`: network/provider error. +- `40`: download error. + +Storage convention: + +- `getter init` creates or opens the canonical getter-owned SQLite storage. It must not initialize JSONL as durable product storage. +- `legacy import-room-bundle` returns a stable unsupported/not-implemented failure for syntactically valid bundles until the real Room import phase is implemented. +- Minimal Phase 1 storage may contain only metadata and empty app/hub tables, but it must be compatible with the accepted Rust-managed SQLite direction. + +## Consequences + +- BDD scenarios can assert stable JSON fields instead of vague text. +- AI/operator workflows get deterministic output from the beginning. +- Early development avoids accidentally treating platform defaults as part of the contract. +- Human-friendly CLI output remains possible later, but it must not destabilize automation. + +## Alternatives considered + +- Human-readable output by default with `--json` opt-in. Friendlier for terminals, but risks making prose the accidental contract. +- Plural commands such as `apps list`. This is common in some CLIs, but the canonical plan already uses singular `app list` and `hub list`. +- Platform-default data directory from the start. This is convenient for users but makes early BDD tests less isolated and can hide state leakage. + +## Implementation note + +Phase 1a executable CLI feature files are now implemented. Future changes should extend this contract explicitly rather than treating it as provisional. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 000000000..0ee95a8e8 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,15 @@ +# Architecture Decision Records + +ADRs in this directory explain decisions that are costly to reverse, surprising without context, and the result of real trade-offs. + +This directory is historical/refactor-phase material. The canonical architecture ADR set lives under `docs/architecture/adr/*`. + +Current refactor ADRs: + +- [0001: Flutter shell with getter-owned product logic](0001-flutter-shell-rust-core.md) +- [0002: Rust-managed SQLite storage](0002-rust-sqlite-storage.md) +- [0003: Source-level page customization](0003-source-level-page-customization.md) +- [0004: First-class legacy Room migration](0004-legacy-room-migration.md) +- [0005: TDD and Cucumber behavior coverage policy](0005-tdd-bdd-cucumber-policy.md) +- [0006: Getter as both library and CLI](0006-getter-library-and-cli.md) +- [0007: Getter CLI command contract](0007-getter-cli-command-contract.md) diff --git a/docs/ai-development.md b/docs/ai-development.md new file mode 100644 index 000000000..c98e4fedc --- /dev/null +++ b/docs/ai-development.md @@ -0,0 +1,54 @@ +# AI Development Workflow + +This repository is being prepared for a test-driven Flutter + getter rewrite. + +## Baseline protection + +- Preserve user work before syncing or rewriting. +- Current planning baseline: superproject `4a1aae1d44a418989b0d3d28528cacff0cc066c0`, getter submodule `f011d9b4b9a15f83cd39c86e781ad8830a8ecae6`. +- The canonical 06-20 plan is copied at `docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md`. +- The pre-sync implementation stash is historical context, not accepted architecture. +- Do not apply stash contents wholesale without a fresh review against the ADRs and the canonical plan. + +## Required loop + +For every behavior change: + +1. Identify whether the behavior is user-facing or internal. +2. User-facing App/CLI behavior: add or update a Cucumber/Gherkin scenario first. +3. Internal behavior: add or update the smallest native unit/integration test first. +4. Confirm the test fails for the expected reason. +5. Implement the smallest change. +6. Run focused validation. +7. Run `just verify` before reporting completion. + +## User-facing BDD scope + +Complete BDD coverage is required for: + +- UpgradeAll App workflows. +- Getter CLI commands, outputs, errors, and exit codes. +- User-visible migration success/failure/recovery behavior. + +BDD is not required for every private function or algorithm. Internal behavior still requires automated tests through the appropriate native framework. + +## Planning rules + +- Update `CONTEXT.md` immediately when domain terms become clear. +- Add ADRs only for costly, surprising, trade-off decisions. +- Keep getter product behavior out of UI-only code. +- Keep stable test IDs in UI contracts. +- Do not start Flutter screen work before getter contracts and acceptance scenarios exist. + +## Commands + +Use `just --list` to see available commands. + +Phase 0 command expectations: + +- `just status` checks branch/submodule state. +- `just cargo-metadata` checks Rust manifests stay loadable. +- `just gradle-projects` checks Gradle can configure the current project graph. +- `just verify` runs the current lightweight verification skeleton. + +Later phases must extend `just verify` to include the real Cucumber, Rust, Flutter, migration, and Android release checks. diff --git a/docs/app/flutter-ui-feature-parity-and-testing.md b/docs/app/flutter-ui-feature-parity-and-testing.md new file mode 100644 index 000000000..d0e6cdbd6 --- /dev/null +++ b/docs/app/flutter-ui-feature-parity-and-testing.md @@ -0,0 +1,100 @@ +# Flutter UI Feature Parity and Testing Strategy + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Toolchain baseline + +The rewrite's Flutter UI/test baseline is Flutter stable `>=3.44.4` with Dart `>=3.12.2 <4.0.0`. The Android build baseline is Gradle `9.3.1`, Android Gradle Plugin `9.0.1`, and Kotlin Gradle Plugin `2.3.20`. Local validation should use the same current-stable Flutter generation as CI; older Flutter tester/Impeller builds are not an acceptable validation baseline for this rewrite. The Flutter product APK's Android `minSdkVersion` follows the active stable Flutter SDK's `flutter.minSdkVersion` (Flutter 3.44 currently uses API 24), rather than pinning an older product APK baseline below Flutter's supported default. + +## UI feature parity + +The Flutter UI should preserve these user-visible product capabilities unless explicitly deferred: + +- Home module entry and update summary. +- Apps list and Magisk list. +- App detail with version/source/artifact selection. +- App settings/editing. +- Repository/source visibility. +- Installed-app autogen preview and confirmation. +- Download task view and controls. +- Settings. +- Logs. +- Migration/recovery status. +- Yellow warning tag for free-network Lua scripts. + +## BDD vs TDD boundary + +Use mixed BDD and TDD. + +### TDD + +Use TDD for function/domain behavior: + +- Rust functions. +- repository resolution. +- Lua validation. +- migration mapping. +- cache invalidation. +- version comparison. +- download action generation. +- error classification. + +TDD tests should be small, deterministic and focused. + +### BDD + +Use BDD for UI and integration behavior: + +- Flutter flows. +- migration UX. +- installed autogen confirmation. +- yellow network warning tag. +- update/download task flow. + +BDD scenarios act as self-explaining documentation tests. Do not over-test BDD: each scenario should document a meaningful user behavior or integration boundary. + +## Suggested BDD style + +```gherkin +Feature: Installed app autogen + + Scenario: Generate package scripts for installed apps + Given the device has installed apps not covered by official repository + When the user opens Installed Autogen + And confirms the generated list + Then getter writes package scripts to local_autogen + And the apps appear in the app list as generated fallback packages +``` + +## Current Flutter shell slice + +The first Flutter implementation slice is intentionally a shell, not product logic: + +- Product APK entry lives under `app_flutter/`; the legacy Android `:app` UI is reference-only during migration. +- Android release identity remains `net.xzos.upgradeall` for future direct upgrade work. +- Android debug identity is `net.xzos.upgradeall.debug` so Flutter debug snapshots can install beside release builds. +- `UpgradeAllApp` exposes stable route/action/state keys such as `route.home`, `action.open_apps`, `state.apps_list`, and `state.migration_ready`. +- `FakeGetterAdapter` keeps UI routes deterministic for widget tests. +- `CliGetterAdapter` exercises a real getter data directory through the `getter-cli` JSON envelope for development/integration tests. +- ADR-0007 documents the bridge contract and explicitly treats the CLI adapter as a test/development bridge, not the final Android production path. +- Product decisions such as repository resolution, updates, migrations, storage, and downloads still belong in Rust getter. +- Installed-autogen product flows must call getter/native bridge operations that use the Rust-active Android platform adapter from ADR-0009; Flutter should not lead PackageManager inventory scanning through a Dart MethodChannel API. +- CI/release APK artifacts must be built from `app_flutter`, not from the legacy `:app` module. +- The app detail update button may call getter's typed update-check operation, receive a getter-issued opaque `action_id`, submit that `action_id`, and open Downloads. Flutter must not assemble or echo action payloads. +- The downloads route may render getter task/event DTOs read-only and refresh after `RuntimeNotification.task_changed`, but it must not implement a Dart download task state machine, retry policy, or installer semantics. Current-state runtime queries remain authoritative. + +## Test pyramid + +- Many Rust unit tests. +- Moderate Rust integration tests for Lua/package/repository behavior. +- Focused Flutter widget tests for component states. +- Few BDD end-to-end scenarios for critical user flows. + +## Anti-goals + +- Do not use BDD for every function branch. +- Do not test Flutter UI by asserting brittle localized visible strings only. +- Do not duplicate Rust unit coverage in UI tests. +- Do not make migration tests depend on network. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..0104215d2 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,30 @@ +# Architecture Documentation + +This directory records the architecture decisions and design notes for the UpgradeAll rewrite. + +Start here: + +- `upgradeall-getter-rewrite-wiki.md` — main living wiki for the Flutter + Rust getter + Lua package repository redesign. + +Canonical ADRs: + +- `adr/0001-app-centric-lua-package-repository-model.md` +- `adr/0002-getter-flutter-platform-boundary.md` +- `adr/0003-legacy-room-migration.md` +- `adr/0004-sqlite-main-db-and-cache-db.md` +- `adr/0005-lua-package-api.md` +- `adr/0006-package-centric-cli-command-contract.md` +- `adr/0007-flutter-getter-bridge-contract.md` +- `adr/0008-flutter-product-apk-entry.md` +- `adr/0009-android-platform-adapter-and-package-visibility.md` +- `adr/0010-package-metadata-cache-and-version-baseline.md` +- `adr/0011-lua-update-runtime-side-effects-and-events.md` + +Documentation policy: + +- Every important architecture decision should be recorded in this wiki or an ADR. +- Every new module should have a documented responsibility boundary. +- Every cross-boundary API should have a schema document. +- Every migration step should have source/target mapping documentation. +- `docs/architecture/adr/*` is the canonical architecture ADR set. +- `docs/adr/*` is historical/refactor-phase material kept for transition context unless a doc explicitly says otherwise. diff --git a/docs/architecture/adr/0001-app-centric-lua-package-repository-model.md b/docs/architecture/adr/0001-app-centric-lua-package-repository-model.md new file mode 100644 index 000000000..0413572d9 --- /dev/null +++ b/docs/architecture/adr/0001-app-centric-lua-package-repository-model.md @@ -0,0 +1,99 @@ +# ADR-0001: App-centric Lua package repository model + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +UpgradeAll will replace the old hub-app model with an app/package-centric repository model. + +- The primary user-facing object is an App/package, not a Hub. +- Package IDs are readable UpgradeAll namespaces, not UUIDs. +- Examples: `android/org.fdroid.fdroid`, `android/com.termux`, `magisk/zygisk-next`. +- GitHub, F-Droid, Google Play, CoolApk and similar systems are providers/sources/backends, not package identity. +- A single package may have multiple sources. +- Package definitions are Lua files stored in repositories/overlays. +- Repositories have priorities; higher priority wins. +- getter only sees the top-level resolved package for a given package id. + +## Context + +The previous model represented update logic as App + enabled Hub list. This became insufficient because providers describe where metadata comes from, not what the package is; projects publish artifacts in many different layouts; and different sources for the same installed app should normally be sources of one package. + +The new model takes inspiration from Portage/emerge overlays and Funtoo Metatools/autogen, but does not copy ebuild syntax. It uses Lua as an embedded package definition language via Rust getter. + +## Repository layout + +```text +repo/ + repo.toml + packages/ + android/ + org.fdroid.fdroid.lua + magisk/ + zygisk-next.lua + lib/ + github.lua + android.lua + github_android_apk.lua + templates/ + android_installed_app.lua + github_android_apk.lua +``` + +`packages/` contains final package definitions consumed by getter. + +`lib/` contains reusable Lua modules. These are conceptually like eclasses, but the project does not introduce an `eclass` keyword or syntax. + +`templates/` contains Lua generators that output new package Lua file content. Templates are for autogen workflows, not runtime package evaluation. + +## Repository priority + +Default priority convention: + +```text +local 100 user-written overrides, default highest priority +community/official 0 normal remote repositories +local_autogen -1 generated fallback packages from installed inventory +``` + +The user may edit priorities manually. The only hard rule is: higher priority wins. + +## Import and override + +Reusable Lua modules should use native Lua `require` where practical: + +```lua +local github_android = require("lib.github_android_apk") +``` + +Parent package import uses a host helper because package ids contain slashes/dots and repo id must be explicit: + +```lua +local base = package_from("official", "android/org.fdroid.fdroid") +``` + +Override is a Lua helper/metatable concern, not a Rust API concern. Rust validates only the final returned data object. + +## Consequences + +Positive: + +- App identity is readable and user-supportable. +- Multiple sources become package internals rather than top-level user confusion. +- Users can maintain patch stacks by overriding individual package files. +- Autogen can create fallback local package definitions without contaminating user-authored `local` overrides. + +Costs: + +- getter must implement repository resolution, priority, package loading, Lua execution, validation and cache invalidation. +- Package authors need documentation and examples. +- Lua outputs must be strictly validated by Rust. + +## Non-goals + +- No UUID primary identity for packages. +- No runtime UI customization framework. +- No static-template-only system. +- No guarantee that arbitrary user forks never require rebasing. diff --git a/docs/architecture/adr/0002-getter-flutter-platform-boundary.md b/docs/architecture/adr/0002-getter-flutter-platform-boundary.md new file mode 100644 index 000000000..6f17496b6 --- /dev/null +++ b/docs/architecture/adr/0002-getter-flutter-platform-boundary.md @@ -0,0 +1,59 @@ +# ADR-0002: getter / Flutter / platform boundary + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +All product and domain logic belongs in the Rust getter core. Flutter is the only product UI and product APK entry for the rewrite. The legacy Android native UI may remain as reference code during migration, but it is not a shipped rewrite entry path. Android-native code is limited to non-UI platform adapter responsibilities. + +Getter remains a separate reusable git submodule at `core-getter/src/main/rust/getter`, tracking `https://github.com/DUpdateSystem/getter`. UpgradeAll records a gitlink to a getter commit; getter CLI/core implementation belongs in that submodule, not as vendored superproject files. + +The Flutter Android app embeds getter as a Rust library / FFI-style core. The app does not use a standalone getter daemon as the primary path. + +Platform-specific APIs are exposed to getter through documented platform adapter seams so that thread management and platform complexity remain isolated. For Android installed inventory, ADR-0009 supersedes the earlier MethodChannel-led scan idea: Rust/native bridge code is the active caller, initializes JVM/context/classloader handles, and calls Android implementation classes for raw PackageManager facts. + +## getter owns + +- Package/repository model. +- Lua package evaluation. +- Provider/source orchestration. +- Version normalization and comparison. +- Release/artifact selection. +- Update status calculation. +- Download request/action generation. +- Download task state machine. +- SQLite main DB and cache DB. +- Legacy migration/import. +- Diagnostics and event streams. +- CLI behavior. + +## Flutter APP owns + +- UI rendering and navigation. +- Android permission prompts and user-facing permission explanations. +- User confirmation flows. +- Rendering getter-owned DTOs, platform diagnostics, and recovery states. + +## Platform adapters own + +- Raw Android PackageManager installed-package facts exposed through the Rust-active platform adapter accepted in ADR-0009. +- Installed version lookup through platform APIs. +- APK install / Shizuku/root/system installer adapters after installer semantics are accepted. +- Notifications / foreground service integration after background-runtime semantics are accepted. +- SAF/file picker and URI permissions. + +Platform adapters expose facts/capabilities to Rust getter/native bridge code. They must not perform package-id normalization, repository resolution, Lua validation, autogen candidate selection, migration mapping, download retry policy, or storage writes. + +## Boundary rule + +If a workflow should be possible from getter CLI without Flutter UI, it belongs in getter. + +If a workflow requires Android APIs or user-interface rendering, it belongs in the Flutter/platform adapter and is exposed to getter as a platform capability. + +## Testing consequence + +- Rust getter behavior is TDD-tested with unit/integration tests. +- Flutter UI and platform flows are BDD-tested through user-visible scenarios. +- Platform adapters get focused integration tests or fake adapter tests. diff --git a/docs/architecture/adr/0003-legacy-room-migration.md b/docs/architecture/adr/0003-legacy-room-migration.md new file mode 100644 index 000000000..3dde893bd --- /dev/null +++ b/docs/architecture/adr/0003-legacy-room-migration.md @@ -0,0 +1,93 @@ +# ADR-0003: Legacy Room migration + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +Old UpgradeAll user data must migrate automatically and without normal-user manual export/import. + +Migration is intentionally limited and simple. It preserves core user-visible app tracking state, but does not attempt to migrate every complex legacy behavior. + +Complex legacy data such as API keys, auth tokens and unusual Hub configuration may be dropped. + +## Source data + +Legacy Room database: + +```text +app_metadata_database.db +version = 17 +entities = app, hub, extra_app, extra_hub +``` + +## Target data + +Migration writes to: + +- getter main SQLite user state. +- `local` repository package Lua files when necessary. +- migration records table. + +Normal installed-app autogen writes to `local_autogen`, but legacy migration is special: it may generate `local` package files once to preserve explicit old user data. + +## Package ID mapping + +- Android apps: `android/`. +- Magisk modules: `magisk/`. + +## Mapping strategy + +1. Detect legacy Room DB. +2. Use bundled official repository snapshot for matching; do not require network at first launch. +3. For common cases, convert legacy app/cloud config to the new package/user state model. +4. If a package is covered by official repository, point user state at that package. +5. If not covered but common conversion exists, generate a `local` package Lua file. +6. Rare/complex cases migrate installed id/tracked state and surface a missing-package diagnostic. +7. Record migration completion. + +## What can be dropped + +- API keys. +- Provider auth tokens. +- Complex or ambiguous Hub auth. +- Legacy settings whose meaning no longer exists. +- Exotic URL replacement rules that cannot be safely mapped. + +## Implemented direct DB and bridge-bundle slices + +The Rust CLI now has a direct SQLite import slice for copied/checkpointed Room v17 databases: + +```text +getter --data-dir legacy import-room-db +``` + +The direct importer opens the DB read-only, requires `PRAGMA user_version = 17`, reads legacy `app` and `extra_app` rows, maps known app-id keys to `android/` or `magisk/`, writes getter tracked package state plus the `legacy-room-v17` migration record in one transaction, and emits sanitized report counts/warnings. Current `hub` and `extra_hub` rows are not imported as top-level objects; they are counted/dropped with warnings until a later accepted mapping exists. + +The first Flutter/Android migration UX slice adds a no-UI Android platform adapter that locates `app_metadata_database.db`, copies the SQLite triplet (`.db`, `-wal`, `-shm`) into an app-private getter-import path, checkpoints/canonicalizes the copy, and returns that copied DB path to Flutter. A follow-up production bridge slice wires Flutter to the native getter bridge for `importLegacyRoomDatabase` and `legacyReportList`; Flutter starts the flow and renders getter-owned reports, while Rust getter owns the actual import operation and Room-row mapping. + +The host-side CLI also keeps the deterministic JSON bridge bundle for tests and non-Android fixtures: + +```json +{ + "format": "upgradeall-legacy-room-bundle", + "version": 17, + "apps": [ + { + "kind": "android", + "installed_id": "org.fdroid.fdroid", + "official_package_available": true, + "common_conversion_available": false, + "pin_version": "1.20.0", + "favorite": true + } + ] +} +``` + +Both slices map app state into getter tracked package state in `main.db`, write sanitized reports under `migration-reports/`, and record `legacy-room-v17` completion. Unsupported bundle formats/versions and unsupported/malformed databases fail with sanitized recovery reports. + +## Failure behavior + +A single unmapped app must not block the whole app. Global migration failure should lead to a migration/recovery page. A per-app mapping failure should be visible on that app or diagnostics page. The direct DB importer treats malformed optional rows and mixed valid/invalid app rows as warnings, but unreadable DBs, unsupported `user_version`, missing required `app` table, and databases with app rows but zero importable app rows are global failures. diff --git a/docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md b/docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md new file mode 100644 index 000000000..efc5ce60e --- /dev/null +++ b/docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md @@ -0,0 +1,47 @@ +# ADR-0004: SQLite main DB and cache DB + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +getter uses SQLite for backend storage. + +The storage is split into: + +1. Main DB: authoritative user and getter state. +2. Cache DB: derived/evaluated/provider/cache state. + +Users manually corrupting backend storage is considered non-standard usage. getter may fail fast with a clear error. + +## Main DB stores + +- Repository registry and priority. +- Enabled/tracked apps. +- User source priority overrides. +- Ignored versions, pins, favorites. +- Migration records. +- Settings and credential references. +- Operation-specific durable records accepted by later ADRs. ADR-0011 explicitly excludes runtime task state from main/cache DB persistence. + +## Cache DB stores + +- Evaluated package metadata. +- Lua validation results. +- Release candidates and selected latest versions. +- Artifact metadata. +- Provider response cache. +- Search index. + +## Cache invalidation keys + +Cache keys should include repo id, repo revision/hash, package file hash, Lua API version, getter/package schema version, platform target and permissions/network mode. + +## Repo source files + +Package Lua files live in filesystem repositories, not inside the main DB. SQLite records repository path/revision/priority and evaluated/cache results. + +## Rationale + +SQLite is chosen over transparent text files for backend state because mobile app data needs atomic updates, reliable migrations, consistent concurrent operations and robust cache/query behavior. Text-like transparency is preserved at the package repository layer through Lua files. diff --git a/docs/architecture/adr/0005-lua-package-api.md b/docs/architecture/adr/0005-lua-package-api.md new file mode 100644 index 000000000..9a5bd0873 --- /dev/null +++ b/docs/architecture/adr/0005-lua-package-api.md @@ -0,0 +1,58 @@ +# ADR-0005: Lua package API and Rust validation boundary + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +getter embeds Lua for package definitions, reusable package helpers and autogen templates. + +The Lua/Rust boundary is treated as an RPC/serialization boundary: Lua returns JSON-like tables; Rust validates and deserializes them into typed structs. + +Lua scripts do not receive mutable Rust domain objects. + +## Language + +Use Lua via `mlua` unless implementation evidence later proves a blocker. + +## Boundaries + +Lua can use normal Lua tables/functions/metatables, reusable modules via `require`, package import helper for parent packages, and host-provided provider/network APIs based on permissions. + +Rust owns schema validation, typed domain model, persistence, event dispatch, download task state and platform callback dispatch. + +## Lifecycle phases + +App-centric phase names: + +```text +preflight +setup +match +discover +prepare +select +resolve # name still open; alternative: make_actions +post_update +``` + +`plan` is rejected because it is too vague. + +## Network permission model + +Lua has no direct network API by default. + +If a package declares free network permission, getter exposes a direct network host API to that Lua environment and Flutter displays a yellow warning tag in App detail source/version UI. + +This tag is informative and does not block use. + +## Templates + +Templates under `templates/` are Lua generators that output Lua package file content. They are distinct from runtime package modules. + +## Validation + +Rust validates package id/path consistency, known package kind, required fields, installed target schema, phase function presence/type where required, permission schema, action schema and URL/action validity. + +Errors must distinguish Lua runtime errors, schema validation errors and domain validation errors. diff --git a/docs/architecture/adr/0006-package-centric-cli-command-contract.md b/docs/architecture/adr/0006-package-centric-cli-command-contract.md new file mode 100644 index 000000000..bb232ab03 --- /dev/null +++ b/docs/architecture/adr/0006-package-centric-cli-command-contract.md @@ -0,0 +1,149 @@ +# ADR-0006: Package-centric getter CLI command contract + +> Status: Draft / implementation slice accepted +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +The getter CLI is a first-class user-facing interface for exercising Rust getter behavior without Flutter. + +The supported rewrite CLI vocabulary is package/repository-centric. New commands should use `repo`, `package`, `app`, `storage`, and `legacy` nouns. The old `hub` noun is not a new domain model; it is kept only as a temporary Phase 1a compatibility command for legacy/background plans and must not grow into a hub-app architecture. + +Initial implemented grammar: + +```text +getter --data-dir init +getter --data-dir app list +getter --data-dir repo list +getter --data-dir repo add [--priority ] +getter --data-dir repo eval +getter --data-dir repo validate +getter --data-dir package eval [--repo ] +getter --data-dir storage validate +getter --data-dir update check --fixture +getter --data-dir runtime script --script +getter --data-dir debug fake-task submit --request +getter --data-dir debug fake-task run +getter --data-dir debug fake-task list +getter --data-dir debug fake-task cancel +getter --data-dir debug fake-task events --after --limit +getter --data-dir debug fake-task install-result --status +getter --data-dir autogen installed preview --inventory +getter --data-dir autogen installed apply --preview (--accept-all|--accept ...) +getter --data-dir autogen cleanup preview --inventory +getter --data-dir autogen cleanup apply --preview (--accept-all|--accept ...) +getter --data-dir legacy import-room-bundle +getter --data-dir legacy import-room-db +getter --data-dir legacy report-list +getter --data-dir hub list # temporary compatibility only +``` + +Global conventions: + +- `--data-dir ` is mandatory in early development and BDD tests. +- JSON is the default output contract. +- Successful command payloads go to stdout. +- Structured command failures go to stdout as JSON error envelopes when possible. +- Invalid CLI usage may additionally use stderr/help text and exit code `2`. +- The CLI must call Rust getter/storage behavior; it must not duplicate product logic outside getter. +- CLI scenarios must invoke the built binary as an external process. +- `package eval ` without `--repo` evaluates the package from the highest-priority registered repository that contains that package id. `--repo ` evaluates that exact repository and bypasses overlay resolution. + +Success envelope shape: + +```json +{ + "ok": true, + "command": "repo list", + "data": {}, + "warnings": [] +} +``` + +Error envelope shape: + +```json +{ + "ok": false, + "command": "legacy import-room-bundle", + "error": { + "code": "migration.invalid_bundle", + "message": "Legacy Room export bundle is invalid", + "report_path": "/path/to/report.json" + } +} +``` + +The first supported `legacy import-room-bundle` slice accepts a JSON bridge bundle with this shape: + +```json +{ + "format": "upgradeall-legacy-room-bundle", + "version": 17, + "apps": [ + { + "kind": "android", + "installed_id": "org.fdroid.fdroid", + "official_package_available": true, + "common_conversion_available": false, + "pin_version": "1.20.0", + "favorite": true + } + ] +} +``` + +It maps `apps[]` into getter tracked package state in `main.db`, writes a sanitized report under `migration-reports/`, and records `legacy-room-v17` migration completion. Malformed JSON uses `migration.invalid_bundle`; wrong format/version uses `migration.unsupported_bundle`. + +`legacy import-room-db ` is the first direct Room database import slice. It opens a copied/checkpointed legacy SQLite database read-only, requires `PRAGMA user_version = 17`, reads `app` and `extra_app` rows, maps known legacy app-id keys to readable package ids (`android/` and `magisk/`), writes tracked package state and the `legacy-room-v17` migration record in one transaction, and emits sanitized report counts/warnings. Unsupported DB versions use `migration.unsupported_db`; unreadable or malformed DBs use `migration.invalid_db`. A DB with a mix of valid and invalid app rows imports valid rows and reports skipped-row warnings; a DB with app rows but zero importable app rows is treated as `migration.invalid_db` so migration completion is not recorded silently. This command does not import legacy `hub` as a new domain model; current hub/extra_hub rows are counted/dropped with warnings until a later accepted mapping exists. + +`legacy report-list` returns sanitized migration report summaries through the same JSON envelope so app/test adapters do not need to inspect getter's data-directory layout directly. + +The first installed-app autogen slice accepts an Android/platform-provided inventory DTO, computes generated fallback packages in Rust, previews before writing, and applies only after explicit `--accept-all` or `--accept ` confirmation. Getter owns the canonical repository path `/repositories/local_autogen`, fixed repo id `local_autogen`, default priority `-1`, deterministic package file path `packages//.lua`, and `autogen-manifest.json`. Candidates are skipped when any registered repository with priority higher than `local_autogen` already provides that package id. Applying installed autogen writes/registers `local_autogen` package files and tracks accepted packages in `main.db` when they are not already resolved; existing user state (`enabled`, `favorite`, `pin_version`) and existing non-missing resolution metadata are preserved. Cleanup preview/apply only targets manifest-managed `local_autogen` packages missing from the current installed inventory. Cleanup refuses stale/tampered previews that do not match the current manifest and deletes tracked state only for rows still owned by `local_autogen` generated packages. If an existing autogen file was modified, getter preserves that content into the user-authored `local` repo before regenerating or deleting the managed autogen file. + +`update check --fixture ` is the first Phase D offline update-check slice. The fixture contract is explicitly offline and uses `format = "getter-offline-update-check"`, `version = 1`, `package_id`, optional `installed_version`, optional `pin_version` (with transitional `ignored_version` accepted as an input alias), and normalized candidate/artifact DTOs. The command returns `network_required = false`, observed `installed_version`, `effective_local_version`, a getter-owned status (`update_available`, `up_to_date`, or `no_candidates`), the selected update when one exists, and generated download/install action DTOs. An update-selected result must have an actionable artifact; a selected candidate without artifacts is a structured update-check error rather than `update_available` with no actions. It reuses Rust getter update selection and version comparison; it does not run providers, perform downloads, persist download tasks, stream events, or call Android installers. + +The old persisted fake downloader slice is retained only as debug scaffolding under `debug fake-task ...`. `debug fake-task submit --request ` accepts `format = "getter-download-request"`, `version = 1`, `package_id`, `executor = "fake"`, and update actions containing at least one `download` action; an optional `install` action creates an abstract install handoff after a successful fake run. `debug fake-task run ` deterministically advances the fake task to `succeeded`; it performs no network I/O and writes no downloaded bytes. `debug fake-task list` returns persisted fake-task summaries from `main.db`. `debug fake-task cancel ` persists cancellation for `queued`/`running` fake tasks, is idempotent for already-canceled tasks, and rejects terminal success/failure with a structured download error. `debug fake-task events --after --limit ` is a pollable debug event contract with a positive `limit`; it is not the ADR-0011 runtime event model. `debug fake-task install-result --status ` records the platform-side result of an abstract debug handoff; the getter-created `requested` handoff state is not accepted as a platform result. This scaffold is not a product task API. + +ADR-0011 runtime task debugging uses `runtime script --script `. The script command creates one in-memory `GetterRuntime` for that single CLI process, executes scripted operations such as `issue_action`, `submit_action`, `task_start`, `task_complete_download`, `task_user_result`, `task_remove`, and `task_clean`, then drops all runtime task state when the process exits. It exists so CLI tests can cover runtime remove/clean/control semantics without introducing a task database, daemon, or cross-invocation task promise. Product task submission remains getter-issued opaque `action_id` only through the native bridge/runtime operation path. + +`repo validate ` validates a repository path offline without requiring it to be registered first. It returns `valid`, `diagnostics`, `package_count`, and `network_required = false`; diagnostics are getter-owned structured records with stable codes, message, severity, source path, and optional package id/field. + +Exit-code classes: + +- `0`: success. +- `1`: generic structured command failure. +- `2`: invalid CLI usage. +- `10`: data/storage error. +- `20`: migration/import error. +- `30`: future network/provider error. +- `40`: download/task lifecycle error. + +## Context + +The rewrite architecture requires getter core to be independently exercisable before Flutter UI work. A CLI-first spine proves that storage, repository loading, Lua package evaluation, migration error reporting, and later update workflows can run without platform/UI code. + +Older Phase 1a docs accepted `getter hub list` as a temporary smoke command. Newer architecture docs reject hub-app as the future model. This ADR reconciles those facts: `hub list` may remain as a no-op compatibility smoke while package/repository commands become the forward path. + +## Consequences + +Positive: + +- The CLI can be used for BDD/runtime evidence and AI/operator workflows. +- Flutter cannot hide missing getter behavior behind UI code. +- Package/repository terminology stays aligned with ADR-0001. +- Legacy import failures can be tested non-destructively before full Room import exists. + +Costs: + +- Command grammar changes must be documented and covered by Gherkin tests. +- The temporary `hub list` compatibility command must be removed or clearly deprecated later. +- CLI output schemas become a supported automation contract. + +## Non-goals + +- No old hub-app model revival. +- No live network provider behavior in the initial CLI smoke slice. +- No Android/platform DB copy or WAL checkpoint implementation in the CLI contract itself; platform adapters prepare a consistent DB file and getter owns import semantics. +- No Flutter UI behavior in CLI tests. diff --git a/docs/architecture/adr/0007-flutter-getter-bridge-contract.md b/docs/architecture/adr/0007-flutter-getter-bridge-contract.md new file mode 100644 index 000000000..05608caa5 --- /dev/null +++ b/docs/architecture/adr/0007-flutter-getter-bridge-contract.md @@ -0,0 +1,210 @@ +# ADR-0007: Flutter / getter bridge contract + +> Status: Draft / first implementation slice accepted +> Date: 2026-06-22 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +Flutter talks to getter through getter-owned DTOs and JSON envelopes. The initial bridge contract is read-only and snapshot-oriented so Flutter can display real getter state without copying product/domain logic into Dart. + +The CLI JSON envelope from ADR-0006 is the first executable bridge oracle. It is used for development, integration/dev tests, and contract validation. Android production embedding still follows ADR-0002: the app embeds getter as a Rust library / native bridge rather than depending on a standalone long-lived getter daemon as the primary mobile path. + +The first bridge implementation in Flutter therefore has two adapters: + +- `FakeGetterAdapter` for deterministic widget tests and UI shell work. +- `CliGetterAdapter` for development/integration tests against a real getter data directory and the built `getter-cli` binary. + +The CLI adapter is not the final Android production bridge. It exists to make the contract executable before the FFI/native bridge is stabilized. + +## First bridge API surface + +The first accepted API surface is intentionally read-only: + +```text +initialize() +listRepositories() +listTrackedPackages() +evaluatePackage(packageId, repositoryId?) +readMigrationReports() +loadSnapshot() +``` + +The second accepted API surface adds the first legacy migration action boundary: + +```text +importLegacyRoomDatabase(databasePath) +``` + +The Android platform adapter may prepare a copied/checkpointed legacy Room SQLite file and return its path to Flutter, but getter still owns the actual `legacy import-room-db` import semantics. The production Android bridge exposes `importLegacyRoomDatabase` and `legacyReportList` through JNI/MethodChannel by delegating to getter-owned `getter-operations` legacy Room code. Flutter starts the flow and renders getter reports; it must not inspect or map Room tables directly. + +The third accepted API surface adds the production installed-autogen bridge boundary and must follow ADR-0009's Rust-active platform adapter direction rather than a Flutter-led inventory scan: + +```text +previewInstalledAutogen(scanOptions) +applyInstalledAutogen(preview, acceptedPackages) +``` + +The Android product APK packages a slim `:getter_bridge` library under `app_flutter/android/getter_bridge`. It builds the Rust `api_proxy` cdylib and includes only the no-UI native bridge / installed-inventory provider classes needed by the Flutter product path, avoiding the legacy native `:app` UI and old `GetterPort` hub/RPC wrapper surface. `MainActivity` exposes a no-UI `net.xzos.upgradeall/getter_bridge` MethodChannel that derives the app-private getter data directory and forwards migration and installed-autogen requests to JNI entrypoints returning getter-style JSON envelopes. + +Internally, Rust/native bridge code scans Android inventory through the platform adapter, then asks getter-owned shared autogen operations to plan/apply `local_autogen`. `MethodChannelGetterAdapter` consumes the returned getter-style JSON envelopes. Flutter renders getter-owned preview/apply DTOs and scan diagnostics, then passes the package ids from displayed accepted preview candidates back to getter on apply; it must not expose a product Dart `InstalledInventoryPlatform` scanner or convert Android package names into package ids. + +`loadSnapshot()` composes smaller getter-owned read-model operations into the UI shell's first snapshot DTO. In the Android/native product path, `MethodChannelGetterAdapter` calls `readOperation` for `repository_list`, `tracked_package_list`, and `package_eval`; Rust getter reads SQLite repository/tracked-package state and evaluates registered Lua packages. Flutter may parse and combine those returned DTOs for rendering, but must not perform repository resolution, Lua validation/evaluation, version comparison, migration mapping, or update selection in Dart. `readMigrationReports()` must go through a getter operation such as `legacy report-list`; Flutter must not inspect getter's data-directory layout directly. Runtime task rendering must use ADR-0011 runtime task snapshot APIs and opaque action/task controls; Flutter must not synthesize task states, retry policy, installer behavior, or update decisions. + +The first product click-through update flow is: App detail calls a typed getter update-check operation, receives a getter-issued `action_id`, submits only that `action_id` to the process-lifetime runtime, and opens Downloads to query authoritative task snapshots. Flutter may refresh the Downloads page after `RuntimeNotification.task_changed`, but the notification is only a trigger; `task_list`/equivalent runtime queries remain the source of truth. + +## Flutter DTOs + +The Flutter shell may use DTOs that mirror getter output for rendering: + +```text +GetterSnapshot +AppSummary +RepositorySummary +TrackedPackageSummary +PackageEvaluation +MigrationReportSummary +LegacyMigrationImportResult +MigrationWarningSummary +MigrationSourceCounts +RuntimeUpdateCheckResult +RuntimePackageSummary +RuntimeUpdateSummary +RuntimeIssuedAction +RuntimeTaskSnapshot +RuntimeTaskPhase +RuntimeTaskProgress +RuntimeTaskCapabilities +RuntimeTaskDiagnostic +RuntimeNotificationEnvelope +GetterError +InstalledAutogenPreview +InstalledAutogenCandidate +InstalledAutogenSkip +InstalledAutogenScanStats +InstalledAutogenApplyResult +``` + +DTOs are a UI transport shape, not a new product model. Any field whose value requires domain interpretation must be supplied by getter or by a platform capability explicitly documented in a later ADR. + +## JSON envelope contract + +The CLI bridge consumes the ADR-0006 envelope shape: + +```json +{ + "ok": true, + "command": "repo list", + "data": {}, + "warnings": [] +} +``` + +and structured error envelopes: + +```json +{ + "ok": false, + "command": "package eval", + "error": { + "code": "package.eval_error", + "message": "Getter package evaluation failed", + "detail": "..." + } +} +``` + +Flutter adapter code may parse and display these fields, but it must not infer missing domain state from them. If the UI needs a richer field, add it to getter output first and cover it with getter tests. + +For installed-autogen flows, CLI/dev tests may continue to pass fixture inventory JSON to `getter autogen installed preview/apply`. The Android product bridge does not expose that fixture boundary as a Flutter-owned scanning API; it wraps scan + getter autogen planning behind a getter/native bridge operation. + +## Error model + +The bridge maps getter errors into `GetterError`: + +- `code`: stable machine-readable getter/platform code. +- `message`: short user/log-facing message. +- `detail`: optional diagnostic detail. + +Flutter may choose presentation, but the source classification belongs to getter or a documented platform adapter. + +## Legacy migration platform adapter + +Flutter owns the migration screen and user-visible flow. Android-native code exposes a no-UI platform adapter over `net.xzos.upgradeall/legacy_migration` with `prepareLegacyRoomImport`. + +That adapter may: + +- locate `app_metadata_database.db` in the app database directory; +- copy the SQLite triplet (`.db`, `-wal`, `-shm`) into an app-private getter-import path; +- checkpoint/canonicalize the copied database into a standalone SQLite file; +- return `{ found, database_path, message }` to Flutter. + +That adapter must not: + +- show Android-native UI; +- map legacy rows into package IDs; +- decide what fields are dropped/imported; +- write getter storage directly. + +Flutter then calls a getter bridge operation equivalent to `legacy import-room-db ` and renders getter-owned reports. + +## Event model + +The initial bridge slice was snapshot-only. ADR-0011 supersedes the old persisted fake-task CLI scaffold for product task flow: runtime task state is process-memory only in the native getter singleton, `RuntimeNotification.task_changed` is pushed over the bridge, and current-state task query operations remain authoritative. The remaining `debug fake-task ...` CLI commands are development scaffolding, not a Flutter/product task API. CLI runtime task coverage uses `runtime script --script `, which executes within one process and intentionally drops runtime task state after the command exits. + +Flutter should not maintain its own task state machine; it renders getter-owned runtime task snapshots and invokes getter-owned task controls/update operations using opaque `action_id`s. + +Android platform install remains a handoff boundary. Getter may request/record an abstract install handoff, but Android permissions, notifications, PackageInstaller/Shizuku/root execution, and path-versus-URI/SAF semantics belong to platform adapter work and remain outside this bridge slice. + +## Android production bridge direction + +The Android production path should embed getter through a native bridge once the DTO contract is stable. The native bridge should expose getter-owned operations and platform callbacks/capabilities; it should not force all in-app UI calls through a heavyweight local JSON-RPC server unless a future ADR accepts that lifecycle cost. + +Local RPC remains acceptable for debug tooling, external integration, and development workflows. + +## APIs forbidden in Flutter UI code + +Flutter UI code must not implement: + +- repository priority/overlay resolution +- Lua package validation or evaluation semantics +- version comparison/update selection +- legacy Room mapping decisions +- cache invalidation rules +- provider/source selection +- download task state machines +- package ID normalization beyond display-safe handling + +If a feature requires one of these decisions, add or extend a getter operation instead. + +## Consequences + +Positive: + +- The early bridge was executable in CI before the native bridge stabilized. +- CLI output remains a headless oracle for storage/repository/migration coverage. +- Flutter can consume real getter data while preserving the Rust-owned domain boundary. +- The native bridge now has a concrete DTO/error/runtime notification contract to preserve. + +Costs: + +- The CLI adapter is development/test infrastructure, not the final mobile path. +- Runtime task UI still exposes only the first read-only snapshot rendering slice until live provider/downloader/installer ADRs are accepted. +- Getter output schemas must evolve carefully because they are now a cross-boundary contract. + +## Validation + +The first implementation slice must provide: + +- Flutter widget tests that continue to use `FakeGetterAdapter`. +- Flutter widget tests for the migration flow using fake platform/getter adapters. +- A Flutter/Dart integration test that builds or receives a real `getter-cli` binary, initializes a real getter data directory, and reads repositories, tracked packages, package evaluation output, migration reports, and direct Room import output through `CliGetterAdapter`. +- `just verify` coverage for the bridge integration test. + +## Non-goals + +- No product-complete live provider/downloader/installer execution beyond the ADR-0011 in-memory runtime operation and notification skeleton. +- No durable update/download/install event log or cross-process task recovery. +- No Android-owned legacy Room mapping/import semantics; Android only prepares a copied DB file for getter. +- No product-complete Flutter UI. +- No product/domain decisions in Dart. diff --git a/docs/architecture/adr/0008-flutter-product-apk-entry.md b/docs/architecture/adr/0008-flutter-product-apk-entry.md new file mode 100644 index 000000000..be99739e5 --- /dev/null +++ b/docs/architecture/adr/0008-flutter-product-apk-entry.md @@ -0,0 +1,50 @@ +# ADR-0008: Flutter product APK entry + +> Status: Accepted +> Date: 2026-06-23 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +`app_flutter/` is the only product Android application entry for the rewrite. + +The legacy Android `:app` module and its native Activity/Fragment/XML UI are kept temporarily as reference code only. They must not be treated as the shipped product APK path for the rewrite, and new product UI flows must not be added there. + +All user-visible flows migrate into Flutter. Android-native code remains allowed only for non-UI platform adapter responsibilities such as: + +- legacy Room database copy/checkpoint handoff; +- installed package inventory facts exposed through the Rust-active platform adapter from ADR-0009; +- Android permission prompts and capability adapters; +- SAF/file picker and URI permission plumbing; +- installer handoff adapters; +- notifications/foreground-service integration after the background-runtime design is accepted; +- native/FFI bridge code that exposes getter/platform DTOs to Flutter. + +## Build and release consequences + +- Android CI and release APK artifacts build from `app_flutter`, not the legacy root Gradle `:app` module. +- `app_flutter` keeps the production package name `net.xzos.upgradeall` for release builds. +- `app_flutter` debug builds use `net.xzos.upgradeall.debug` so debug snapshots can be installed beside the release package. +- Release signing belongs to the Flutter Android project. CI writes `app_flutter/android/key.properties` from repository secrets and runs `flutter build apk --release`. +- The old `:app` Gradle module may still be checked for reference/skeleton integrity, but `./gradlew :app:assembleDebug` or `./gradlew :app:assembleRelease` is no longer the product APK build path. + +## Rationale + +The rewrite goal is Flutter APP + Rust getter core + Lua package repositories. Keeping the native Android UI as the launcher would preserve the old shell as a product dependency and blur ownership boundaries. Making `app_flutter` the product APK entry lets Flutter own all screens and navigation while Rust getter owns product/domain/storage logic. + +Keeping the old native UI source temporarily reduces migration risk: it remains available for parity comparison while individual flows are rebuilt in Flutter. + +## Non-goals + +This ADR does not delete the legacy `:app` module yet. + +This ADR does not approve live provider/downloader/background-worker/installer runtime semantics. Those remain separate Phase D decisions. + +This ADR does not claim the current Flutter shell is product-complete. Until the production native/FFI bridge exists, CI can validate getter next to the Flutter APK, but the APK remains a rewrite shell/snapshot rather than a fully wired getter product. + +## Follow-up + +- Move every user-facing entry and flow into Flutter. +- Add platform adapters only where Android APIs are required. +- Delete or archive legacy native UI code after Flutter feature parity is reached. +- Once the production getter bridge exists, add APK-level validation that the Flutter product APK contains and exercises the intended native getter bridge. diff --git a/docs/architecture/adr/0009-android-platform-adapter-and-package-visibility.md b/docs/architecture/adr/0009-android-platform-adapter-and-package-visibility.md new file mode 100644 index 000000000..97a777de5 --- /dev/null +++ b/docs/architecture/adr/0009-android-platform-adapter-and-package-visibility.md @@ -0,0 +1,162 @@ +# ADR-0009: Android platform adapter and package visibility + +> Status: Accepted for first implementation slice +> Date: 2026-06-23 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +UpgradeAll will use a Rust-active platform adapter for Android platform capabilities. + +Rust/getter-side native code defines the platform interface and actively calls the Android implementation. Android/Kotlin code supplies raw platform facts only. Flutter remains the product UI and renders getter-owned DTOs; it does not lead installed-app inventory scanning or turn Android package names into UpgradeAll package ids. + +The first accepted platform capability is installed Android package inventory for `local_autogen` preview/apply workflows. The product Flutter APK declares: + +```xml + +``` + +This is an explicit product/distribution decision: UpgradeAll is an app updater and installed-app tracker, so broad installed-package visibility is core functionality rather than incidental implementation convenience. + +## Rust-active adapter pattern + +The Android implementation follows the same architectural pattern as `rustls-platform-verifier`: + +1. A JNI entrypoint initializes platform access with the current JVM, application `Context`, and app `ClassLoader`. +2. Rust stores process-lifetime global references. +3. When Rust needs a platform capability, it attaches the current thread to the JVM, loads Android implementation classes through the app classloader, and calls static Kotlin/Java methods. +4. Kotlin/Android code returns data facts in a stable transport shape. +5. Rust validates/deserializes those facts before passing them to getter-owned workflows. + +Rust must not use Android `FindClass` from arbitrary background threads for app classes. App classes are loaded through the stored app classloader. + +## Installed inventory contract + +The platform adapter returns a wrapper result: + +```json +{ + "inventory": { + "format": "upgradeall-installed-inventory", + "version": 1, + "items": [ + { + "kind": "android_package", + "package_name": "org.fdroid.fdroid", + "label": "F-Droid", + "version_name": "1.20.0", + "version_code": 1020000 + } + ] + }, + "stats": { + "total_seen": 123, + "returned": 42, + "filtered_system": 80, + "filtered_self": 1 + }, + "diagnostics": [] +} +``` + +The installed inventory is getter-compatible, but it remains raw platform fact data: + +- Android supplies `package_name`, label, version name, and version code. +- Android/Flutter must not generate `android/` package ids. +- Android/Flutter must not decide repository coverage, autogen candidates, generated Lua file paths, or tracking-state writes. +- Magisk modules are not part of this PackageManager capability. They require a separate root/Shizuku/Magisk capability decision. + +## Scan options + +The first scan options are: + +```json +{ + "include_system_apps": false, + "include_self": false +} +``` + +Defaults exclude system apps and the UpgradeAll application itself. Disabled-app filtering is not part of the first Rust interface; disabled packages are treated as installed PackageManager facts until a later product decision defines user-facing semantics. + +## Getter and Flutter responsibilities + +The product operation shape is: + +```text +Flutter UI + -> getter/native bridge: preview installed autogen + -> Rust platform adapter: scan installed inventory facts + -> getter core: plan local_autogen candidates/skips + <- getter-owned preview DTO +``` + +Flutter may ask getter for preview/apply operations and render scan stats/diagnostics returned by getter. Flutter must not implement a separate Dart `InstalledInventoryPlatform` scanner or MethodChannel-led inventory flow for the product path. + +CLI/dev workflows remain fixture-based: + +```text +getter autogen installed preview --inventory installed.json +getter autogen installed apply --preview preview.json --accept-all +``` + +The CLI has no Android PackageManager, so fixtures remain the headless oracle for getter domain behavior. + +## Permission policy + +`QUERY_ALL_PACKAGES` is declared only in the Flutter product APK manifest path (`app_flutter`). The legacy native `:app` module remains reference-only and is not the rewrite product APK path. + +The permission may have distribution-policy implications on app stores. The project accepts that trade-off for the rewrite product because full installed-app visibility is necessary for UpgradeAll's app-updater inventory and autogen workflows. + +If lint/build tooling flags `QUERY_ALL_PACKAGES`, the manifest may suppress that lint with an inline comment and `tools:ignore="QueryAllPackagesPermission"`; this suppression must remain documented as policy, not treated as a generic lint cleanup. + +## Implementation slices + +The first slice was intentionally narrow: + +- document this ADR and update existing boundary docs; +- add `QUERY_ALL_PACKAGES` to the Flutter product manifest; +- add a superproject Rust crate for platform adapter DTOs, errors, a `NoopPlatformAdapter`, and Android runtime/JNI initialization skeleton; +- add validation for that crate. + +The second slice adds the first Android facts provider while preserving the same boundary: + +- `net.xzos.upgradeall.getter.platform.InstalledInventoryProvider` is a no-UI Kotlin provider called by Rust JNI through the app classloader; +- Kotlin `InstalledInventoryScanner` collects raw PackageManager facts and encodes the getter-compatible installed inventory JSON; +- Kotlin collector tests cover filtering, sorting, duplicate handling, and contract format without constructing package ids; +- Rust `AndroidPlatformAdapter::scan_installed_inventory` serializes scan options, calls the provider, and deserializes the JSON into platform DTOs; +- `api_proxy` initializes the platform adapter runtime alongside `rustls-platform-verifier`, using separate JNI local refs for each initializer. + +The third slice wires the first product bridge operation without changing ownership boundaries: + +- installed-autogen preview/apply semantics are extracted into reusable getter-owned `getter-operations` code so CLI and native bridge use the same `local_autogen` rules; +- `getter-core` Lua support is feature-gated so the Android native bridge can use autogen/storage operations without pulling Lua evaluation into `api_proxy`; +- `api_proxy` exposes JNI entrypoints for bridge initialization, installed-autogen preview, and installed-autogen apply; +- preview initializes the Rust-active Android platform adapter, scans PackageManager facts, and passes getter-compatible inventory into getter-owned autogen planning; +- apply validates the preview/acceptance and uses the same getter-owned apply code as the CLI; +- `app_flutter/android/getter_bridge` is a slim Android library that packages `libapi_proxy.so`, `NativeLib`, and the facts provider classes into the Flutter product APK without depending on the legacy native `:app` UI or old `GetterPort` hub/RPC wrapper surface; +- `just verify` now inspects the Flutter debug APK for the native bridge library and provider classes. + +These slices still do not: + +- make the reusable getter submodule depend on superproject-only crates; +- add Magisk scanning; +- add live downloads, background worker policy, installer URI/SAF semantics, or notification behavior. + +A follow-up slice added the first Flutter installed-autogen preview/apply confirmation UI. It consumes getter-owned DTOs from `MethodChannelGetterAdapter` and passes displayed accepted package ids back to getter; it still does not scan PackageManager or generate package ids in Dart/Flutter. + +## Consequences + +Positive: + +- Rust remains the active caller and owner of platform interface shape. +- The platform seam is testable with host DTO tests before device integration exists. +- Flutter cannot accidentally become the owner of inventory/autogen decisions. +- The model can later support other Android capabilities using the same runtime initialization pattern. + +Costs and risks: + +- The current bridge slice is not yet product-complete because device/instrumented runtime verification is still pending. +- The Rust platform DTOs must stay compatible with getter's installed inventory contract. +- JNI/runtime bugs require Android build/device validation beyond host unit tests. +- Broad package visibility is now an explicit product policy with distribution implications. diff --git a/docs/architecture/adr/0010-package-metadata-cache-and-version-baseline.md b/docs/architecture/adr/0010-package-metadata-cache-and-version-baseline.md new file mode 100644 index 000000000..1073b27db --- /dev/null +++ b/docs/architecture/adr/0010-package-metadata-cache-and-version-baseline.md @@ -0,0 +1,155 @@ +# ADR-0010: Package metadata cache and version baseline + +> Status: Accepted +> Date: 2026-06-24 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Decision + +UpgradeAll's rewrite uses getter-owned package metadata caching and getter-owned version-baseline semantics. The cache is persisted in `cache.db`; user version override state is persisted in `main.db`; package Lua/templates own local-version acquisition and normalization through the complete lifecycle contract. + +## Package metadata cache + +The runtime caches software metadata produced by running package Lua/provider logic. This cache is analogous in spirit to Gentoo `eix` package metadata caching: it supports fast query/display/update planning over reusable package metadata such as identity, description, homepage/source information, available versions/candidates, changelog or release notes when supplied by sources, artifact descriptors, licenses/tags, and source/provider diagnostics. + +The cache model has two layers: + +1. **Provider/source cache**: provider host API responses keyed by provider id, request parameters, executor/cache policy, auth/permission mode, and other provider-context inputs. +2. **Package metadata cache**: normalized package metadata produced by Lua/package logic from provider/source data. + +Package metadata cache entries are persisted in `cache.db` from the first runtime implementation. + +### Cache key and Lua dependency closure + +Package metadata cache entries must be keyed by the Lua dependency closure and runtime context that can affect metadata, including at least: + +- package Lua file hash; +- loaded template/base class hashes; +- loaded helper module hashes; +- parent package imports and their dependency closure digests; +- Lua API/schema/runtime version; +- platform target and permission/network mode when they can affect produced metadata; +- provider/source cache keys or content digests used to produce the metadata. + +The runtime should automatically track the Lua dependency closure from actual loaded modules/templates/package imports. Explicit dependency declarations may exist only as a supplement or escape hatch for dependencies that the loader cannot otherwise observe. + +If provider/source validation proves data unchanged, the runtime may update checked-at/freshness metadata without replacing the provider body. Package metadata normalization may be skipped only when the Lua dependency closure digest and other package metadata key inputs are unchanged. If the Lua package/template/helper dependency closure changes, the runtime may reuse unchanged provider/source cache as input, but it must rerun Lua normalization and create/update the current PackageMetadata entry for the new closure digest. If provider/source data changes, the runtime updates provider/source cache and reruns package metadata normalization for affected packages. + +### Freshness and refresh + +Freshness should be determined by provider/source freshness tokens when available, with TTL as a fallback revalidation hint. Examples include ETag, Last-Modified, source cursor, upstream index revision, or response digest. TTL expiry means the entry should be revalidated; it does not by itself mean the old cache must be deleted. + +A forced refresh bypasses cached reads for the refreshed scope and, on success, updates or replaces the relevant `cache.db` entries with newly observed source facts. `--refresh` is not a read-only cache bypass mode. If the runtime has successfully observed newer actual provider/package metadata, keeping stale cache entries as the effective cache value is a consistency bug. + +If forced refresh fails, the runtime must not delete still-usable old cache entries merely because the refresh failed. Instead, the operation must report refresh failure and staleness explicitly. If an operation elects to fall back to old cache, the result must make that fallback visible through diagnostics such as `cache.refresh_failed`, `used_stale_cache`, and stale age/cursor metadata. Old cache must not be presented as a successful fresh synchronization. + +`cache.db` is not an audit log. Product semantics only require the current effective cache entry for a package/context. Old provider or package metadata entries may be retained temporarily for debugging or transaction safety, but they can be garbage-collected without preserving a product-visible history. Future metadata history/diff features require a separate design. + +## Artifact descriptors and live versions + +Artifact descriptors inside PackageMetadata are package-management contracts, not mutable cache truth. For a normal versioned release, the artifact URL/locator, size, checksum, signature, and content identity describe the expected file. If upstream changes the file behind the same declared release, or if the downloaded file's metadata/hash/signature does not match, getter must treat it as an invalid artifact/download failure rather than silently accepting the new file or treating the mismatch as a cache refresh. Refreshing metadata may discover a new valid release/artifact descriptor, but it must not launder a mismatched downloaded file into correctness. + +Explicitly live/floating packages, analogous to Gentoo `9999` live ebuilds, are different. Live/floating behavior is a package/Lua-level flag, not an artifact-level flag. Getter/UI must surface live versions before download/task submission because artifact-stage detection is too late for user awareness. + +Live version checks are opt-in and require a separate live flag such as `--live`; live packages do not participate in the ordinary versioned update check by default. The live update rule is intentionally simple: run the live Lua path to obtain the current live version string, compare it with the local baseline, and report an available update when they differ. A live version is an arbitrary valid UTF-8 string; getter does not parse, order, or validate it as a semantic version. A live package may allow Lua to resolve arbitrary/latest upstream artifacts at execution time, but those results are not cacheable as stable artifact metadata because upstream may change at any time and downstream cannot continuously refresh. + +## Installed version entrypoint + +The installed/local version source is part of the completed Lua lifecycle contract. Getter uses an installed version entrypoint/template method to resolve the current baseline and to produce display data. + +For non-live update checks, the effective local baseline is `pin_version` when the user has set one; otherwise getter uses this entrypoint. + +The installed version entrypoint returns a structured value such as: + +```lua +return { + status = "present", + version = "1.2.3", + extra = { + version_code = 123, + }, +} +``` + +or: + +```lua +return { + status = "not_installed", +} +``` + +Platform/API failures use Lua errors such as `error("reason")`, not `not_installed` values. + +Without a `pin_version` override, getter must have a `present` local version to compare. If the entrypoint reports `not_installed`, there is no local baseline to display or compare. If it raises an error, getter reports the Lua/platform version-source error. + +With a `pin_version` override, getter may still call the installed version entrypoint for display. If that call fails, getter reports a local-version diagnostic but continues comparison against `pin_version`. If it reports `not_installed`, UI omits the local version row and still shows/uses `pin_version`. + +For Android apps with a standard version source, the default Lua template can call the getter/platform host API that reads platform-specific package facts such as version name/code, return `not_installed` when the app is absent, and raise a Lua error if the platform call itself fails. Special packages can override or inherit a different Lua implementation. + +For live checks, the installed version entrypoint may return `{ status = "not_installed" }` to mean no local baseline is semantically available; getter then falls back to the last successfully installed/accepted live version recorded in getter state. If the live package's installed version entrypoint raises a Lua error because a platform/API call failed, getter must report that error and must not fall back unless a `pin_version` override supplies the effective baseline for that check. + +## Version model and `pin_version` + +The rewrite does not preserve the old Kotlin version-number stack wholesale. Lua packages/templates own local-version acquisition and normalization through lifecycle inheritance/override. Getter supplies host/platform APIs and small helper tools for common extraction/comparison tasks, such as regex-based extraction and platform facts like Android version name/code, but Lua/template code decides when and how to use them. Legacy invalid/include regex fields are migration inputs or Lua-template helper parameters, not global getter-owned version behavior. + +New rewrite domain language uses `pin_version`, not `ignore_version`: `pin_version` is a persisted user-selected local version override stored in `main.db` tracked package state, not a transient update-check parameter and not cache data. + +In the first implementation, `pin_version` is a scalar UTF-8 string so CLI usage stays simple, e.g.: + +```bash +getter version pin +getter version unpin +``` + +Pin/unpin commands mutate durable getter state. When set, getter compares upstream candidates against `pin_version` as the effective local version instead of the platform/Lua-installed version result. Other version comparison behavior remains the normal package/version comparison behavior. + +UI/CLI display should still show both observed local version and `pin_version` when an observed local version exists: + +- Flutter shows local version above and bold pin version below, with latest version on the right. +- CLI compact display uses `version: (~~)` where the tilde-marked value is the pin override. + +If local version acquisition errors while `pin_version` is set, the check may still proceed using `pin_version`, but the error must be visible as a diagnostic. If the package is explicitly not installed/no-local, UI omits the local version row instead of showing an error. + +Legacy Room `ignore_version_number` and transitional `ignored_version` inputs map into rewrite `pin_version`; new rewrite storage, DTOs, and Flutter UI should emit/use `pin_version`. Legacy migration reports must emit an informational rename note such as `migration.renamed_ignored_version_to_pin_version` so reviewers/users can see that the setting was preserved under the new name. + +If structured pin metadata or extra fields are needed later, CLI must expose ergonomic flags or a separate advanced command rather than requiring users to type raw JSON. + +## Non-authoritative cache boundary + +The package metadata cache must not be the authoritative store for: + +- tracked/enabled packages; +- favorites; +- pin_version override state; +- final user-state-dependent selected update status; +- download task state; +- installer handoff results; +- Flutter UI state. + +Those remain main DB or operation-specific state. + +## Consequences + +Positive: + +- Cache invalidation follows the effective Lua dependency closure instead of fragile manual cache-clearing. +- Forced refresh semantics preserve cache consistency while keeping stale cache available after failed refreshes. +- Version baseline logic becomes explicit and user-visible. +- `pin_version` naming better matches the actual behavior than legacy `ignore_version` terminology. +- CLI remains ergonomic for pin/unpin workflows. + +Costs: + +- The runtime loader must track actual Lua/template/module/package imports. +- Cache keys become more complex than a package file hash. +- Tests must distinguish provider/source cache behavior from package metadata cache behavior. +- Existing rewrite code using `ignored_version` must be renamed before these DTOs/storage names become stable product API. + +## Follow-up implementation notes + +- Rename rewrite-facing `ignored_version` fields, storage columns, fixtures, and DTOs to `pin_version`. +- Keep accepting legacy names only at migration/import boundaries when needed. +- Add migration report notice `migration.renamed_ignored_version_to_pin_version`. +- Add getter CLI commands for durable pin/unpin. +- Add Lua/helper API for version extraction/comparison without preserving the old Kotlin version stack wholesale. diff --git a/docs/architecture/adr/0011-lua-update-runtime-side-effects-and-events.md b/docs/architecture/adr/0011-lua-update-runtime-side-effects-and-events.md new file mode 100644 index 000000000..018dff49a --- /dev/null +++ b/docs/architecture/adr/0011-lua-update-runtime-side-effects-and-events.md @@ -0,0 +1,78 @@ +# ADR-0011: Lua update runtime, side effects, and runtime events + +> Status: Accepted +> Date: 2026-06-24 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Context + +ADR-0010 accepts the package metadata cache, version baseline, `pin_version`, installed version entrypoint, and live-version rules. + +This ADR accepts the first Phase D runtime architecture for the getter-owned Lua update runtime: + +- runtime notification callback and Flutter push-stream bridge shape; +- side-effect executor boundaries; +- mock provider/download/install executors for the first implementation slice; +- operation DTO boundaries for CLI/native bridge/Flutter; +- in-memory task/action lifecycle, controls, user-result, retry, and cleanup semantics. + +Future Android download/install/background/system-notification semantics remain deferred to later ADRs. + +## Current settled boundaries carried forward + +UpgradeAll's Phase D runtime remains getter-owned and cross-platform. Flutter subscribes/renders getter-owned DTOs/events and must not own provider selection, version comparison, cache invalidation, download task state machines, retry policy, installer semantics, or local-autogen/package-id decisions. + +The Lua update runtime is not merely `mlua` file evaluation. It loads a package and its Lua template/base/helper dependency closure, materializes a complete lifecycle contract, validates it, invokes scenario-specific lifecycle entrypoints, exposes getter-owned host APIs, and emits getter-owned task/runtime events for UI subscribers. + +External side effects may remain mocked in the first implementation slice. Mock side effects are a development/implementation scaffold, not a product architecture decision. The architecture decision is the runtime shape and its boundaries. The first mock download executor simulates progress and task state only; it does not write real files, validate real artifacts, or perform artifact handoff. The first mock install executor also simulates state only and does not trigger a real Android installer/handoff, but it must exercise a fake user-waiting handoff state using `status = running` and `phase = { category = "waiting_user", reason = "install_handoff" }`. Fake waiting-user install handoff does not auto-complete; it is completed through a generic task-level `user-result` method so future real installer, permission, SAF, confirmation, or other user-mediated callbacks and tests share the same task continuation boundary instead of special-casing install. `user-result` uses user-facing semantic outcomes `accepted` and `rejected` rather than raw task terminal statuses; getter maps those outcomes to the next task state or continuation behavior. `user-result` does not include a `canceled` outcome: canceling the whole task remains the separate `task cancel` method. `accepted` represents the successful/accepted outcome of the user-mediated step at the granularity the platform adapter can observe; Android does not provide a stable separate boundary for "user just agreed but install is not complete", so the rewrite does not introduce a separate `completed` user-result outcome. For fake install handoff, `accepted` continues the mock install and completes the task as `completed`; `rejected` maps to `failed`, not `canceled`, so an accidental rejection remains retryable on the same task. `rejected` may include an optional reason; if omitted, getter supplies a default current diagnostic such as `user.rejected`. `user-result` is valid only while the task is in a user-waiting phase such as `{ category = "waiting_user", reason = "install_handoff" }`; calling it in any other phase/status is an API error and must not mutate task state. For Android installer callbacks, the installer UI's "cancel" result is treated as this user-mediated `rejected` outcome rather than task cancellation. + +### Complete lifecycle contract + +For getter runtime purposes, lifecycle functions are not optional. A package consumed by the runtime has a complete lifecycle contract after Lua templates/base classes fill default implementations. + +Template defaults are authoring convenience. Getter should validate and run the completed contract rather than treating absent lifecycle functions as normal runtime state. + +### Entrypoint-oriented execution + +Getter invokes scenario-specific lifecycle entrypoints, not a hard-coded global lifecycle sequence on every operation. + +Examples: + +- installed matching may invoke a matching entrypoint; +- update checking may invoke the update/check entrypoint that internally calls discovery/prepare/select/resolve helpers as its Lua/template contract defines; +- post-update behavior runs only after an update/install result exists. + +Getter should not hard-code the internal Lua call graph. Once getter invokes the selected entrypoint, Lua/template code may call other lifecycle functions or helpers within the validated contract. + +Stable Flutter/product APIs should be getter operations such as update check, task submission, cancellation, installed-autogen preview/apply, and task/event retrieval. Direct calls to individual Lua lifecycle functions are diagnostic/test tooling, not the product bridge contract. + +### Provider host API, not default raw HTTP + +Package Lua should call getter-owned provider/source host APIs by default rather than performing arbitrary direct HTTP. + +The provider executor behind the host API may be fake/mock during the first runtime implementation and live later. Caching, diagnostics, permissions, and output validation remain part of the getter-owned runtime boundary. + +Direct/free-network Lua remains a separately declared permission path as described in ADR-0005. It is not the default provider model for normal packages. + +### Runtime event callbacks + +The first Phase D runtime slice should define a getter-owned runtime callback boundary so native/Flutter UI can learn that task/runtime state changed and what it changed to, without owning task state machines. + +The callback is a notification mechanism, not the source of truth and not a persisted event log. Task state is current getter-runtime process state only: it is never persisted to `main.db` or `cache.db`, and UpgradeAll does not support cross-process, app-restart, device-sleep, or downloader-style resume/recovery semantics because UpgradeAll is not a general-purpose downloader. After Flutter/Android/getter runtime process restart, the in-memory task registry is empty unless new tasks have been created in that process; UI must not present old tasks as recoverable or show a downloader-style interrupted-task recovery prompt. If the user still wants to update, they start again from the package/update action. Any residual temporary-file cleanup belongs to the specific executor implementation and is not task recovery. Getter is a monolithic package-manager runtime object held by the product app/native bridge process, not a task daemon. In the native bridge product path, getter runtime is a process-lifetime singleton: native bridge initialization creates or retrieves the runtime, Flutter route/page rebuilds do not recreate task state, and app process death drops runtime/task state. First implementation supports a single main app engine callback binding and exposes runtime notifications to Flutter as a push stream, e.g. Android/Kotlin bridge `EventChannel` or equivalent. Product bridge is not polling-only: `RuntimeNotification.task_changed` snapshots are pushed to Flutter, while `task get`/`task list` remain authoritative query operations when UI/CLI needs current state. The native/Rust bridge product path supports one main Flutter subscription; if multiple Flutter pages/components need the stream, Dart owns broadcast/state-management fanout inside the app rather than requiring Rust/native multi-subscriber semantics. The stream is best-effort push: it should push notifications whenever it can, but it is not a reliable message queue and does not guarantee delivery of every intermediate progress snapshot. The bridge must avoid unbounded backpressure queues; it may coalesce/drop intermediate progress notifications, while authoritative current state remains available through `task list`/`task get`. The stream does not replay notifications missed while Flutter is unsubscribed or disconnected; reconnecting Flutter should resubscribe and immediately query current task state with `task list`/`task get`, then process newly pushed notifications. CLI/debug tooling may use query/polling/scripted commands instead of product stream semantics. Multi-engine or multi-isolate sharing semantics require a later ADR if needed. Product task submission uses a getter-issued opaque `action_id`. Update/check operations may send Flutter rich display DTOs for package/version/action presentation, but Flutter acts by returning only the `action_id`; it must not construct tasks by assembling or echoing raw URLs, checksums, installer types, package IDs, versions, or full action payloads itself. The getter runtime's internal action registry may and should hold a sealed action plan with full execution details such as package id, target version/live revision, artifact descriptors, checksum/signature expectations, installer/executor plan, and the bound Lua/package execution context; those details are internal getter execution data, not product bridge input. The sealed plan must be bound to the Lua/package context that produced the action and must not re-read current Lua files during submit or retry. The runtime should load, validate, and materialize the package/template/helper context into a package-version Lua object in memory before issuing the action, then execute that bound in-memory object/plan rather than behaving like a shell that reads and executes one line at a time. If task execution later needs Lua hooks or helper functions, it calls the bound package-version Lua object; the entire Lua call chain used by that object is already loaded/materialized in memory and is not resolved again from the filesystem. The package-version Lua object lives with the action/task that needs it: the action registry holds it until the action is consumed or expires; successful submit consumes the action and transfers the sealed plan/object to the task; failed tasks keep enough of the object to support retry; completed/canceled tasks may release it when the task is cleared from the current runtime registry; expired unsubmitted actions release it when the action registry cleans them up. `action_id` is scoped to the current getter runtime process and is not a persisted cross-process handle. `action_id` is single-use: once task submission successfully creates a task, getter consumes/removes that action from the runtime action registry. A consumed action is permanently gone within that runtime: it is not restored if the created task later fails, and it must not be reused because reuse would blur action lifecycle and task lifecycle. Reusing the same consumed action, such as from a UI double-submit, returns `action.not_found` instead of creating another task. If task submission references an expired or unknown `action_id`, getter returns `action.not_found` and must not automatically re-run update check or attempt to match a fresh package/version candidate, because that could change the candidate the user saw. Flutter should prompt the user to refresh package/update state and submit a new current-runtime action. CLI/debug tooling may use fixtures/scripts or full request JSON for tests, but the product bridge must stay anchored to getter-owned update/package operations. Runtime task scheduling does not impose a global serial queue or task-registry-level cross-task lock: concurrent downloads and concurrent installs are allowed, and tasks behave like independent branches in a task tree/forest. This tree/forest wording is only a mental model for independence, not an exposed parent/child task API. The first runtime does not need `parent_task_id` or visible download/install subtasks unless a later batch-update ADR introduces them. Tasks do not know about, wait for, or coordinate with sibling tasks through the runtime scheduler. However, package installation/state mutation for the same package is a package-scoped resource and must be protected by a package-level lock inside the relevant executor/operation. Installing the same package, whether for the same version or different versions, must not run its package mutation critical section concurrently. This package lock must not be implemented as task creation rejection, task deduplication, task merge, or a global task lock; if two tasks for the same package are created due to user action, race, or bug, the tasks may both exist. The package lock is non-waiting: when a task reaches the package mutation/install critical section and the same package is already locked by another task, this is treated as incorrect usage and the later task fails immediately with a user-visible diagnostic instead of waiting for the lock or entering a resource-waiting phase. The diagnostic code is `package.locked`, and task phase reason can use `package_locked` to make clear that the failing resource is the package mutation boundary, not the task scheduler. Download execution is task-local and does not use the package-level lock; even if two tasks download the same artifact, they are independent task-internal effects until a later package mutation/install boundary is reached. CLI task commands do not promise state across separate CLI invocations and must not create a task DB or daemon just to make pause/resume/user-result work across commands; tests should exercise the Rust runtime library directly or through a single-process scripted/debug command when CLI coverage is useful. The top-level payload is a generic `RuntimeNotification` with a `kind` discriminator. The first product kind is `task_changed`, carrying a lightweight but sufficiently complete current task snapshot. Callback payloads must include enough task snapshot data so Flutter can update UI without querying getter after every notification; this avoids unnecessary backend pressure. The current task snapshot includes task-state fields such as `task_id`, `package_id`, `status`, structured `phase`, `progress`, current control `capabilities`, optional `current_diagnostic`, and an `updated_at`/snapshot timestamp for UI ordering or stale-update handling. The snapshot is task state only, not package metadata or duplicated display metadata; external UI/callers are expected to know or query package metadata through the proper package/update APIs. Task progress supports at least `percent` and `bit` units; when bit-level current/total values are available, callbacks should prefer `bit` because it carries more information and percent can be derived from it. Task control supports cancel, retry, pause, and resume from the first runtime implementation. These controls are methods on an existing task object, not factories for new tasks. `task cancel` is valid for active `queued`, `running`, and `paused` tasks; a paused task can be canceled directly without first resuming. It is not valid for `failed`, `completed`, or already `canceled` tasks. Failed tasks should use retry or remove, while completed/canceled tasks are terminal. Task status values are `queued`, `running`, `paused`, `failed`, `completed`, and `canceled`; the rewrite uses `completed` and does not keep a `succeeded` alias. Waiting for user action is not a status: the task remains `running`, and the phase is structured as `{ category, reason? }`, e.g. `{ category: "waiting_user", reason: "install_handoff" }`. The first phase schema intentionally avoids extra fields such as detail/executor/localized message; progress, diagnostics, and UI text are separate concerns. In particular, `retry` retries the same task identified by the same `task_id`; it must not automatically create a new task. Only `completed` and `canceled` are truly terminal task states. Failed tasks are not terminal: if the current task state permits retry, retry reuses the same `task_id` and transitions the existing task back into a runnable state. Retry is a task method over the sealed, already-consumed action plan, not action reuse and not an implicit update refresh. It does not revalidate against the action registry, re-run update check, or match a fresh candidate; if the sealed artifact/package plan has become invalid, the retry fails naturally in the relevant execution phase such as download, validation, package lock, or install. It should resume from the failed phase when the runtime has enough task-local state: download failures retry download, fake install `rejected` failures retry the install handoff, and `package.locked` failures retry entering the package mutation boundary. If the runtime lacks enough intermediate state for a precise phase retry, it may restart from the task-internal action plan beginning, but still as the same task and without creating/restoring an `action_id`. If retry itself fails, getter returns an error and the caller may invoke retry again on the same task when the task state still permits retry. Completed, canceled, and failed tasks remain queryable task objects in the current runtime process indefinitely until an explicit manual cleanup operation removes them; there is no automatic TTL/capacity retention cleanup and no persistent storage. CLI should expose both `task remove ` for removing one in-memory task and `task clean` for explicit bulk cleanup of non-active in-memory tasks. `task remove ` is an explicit single-task operation and may remove `failed`, `completed`, or `canceled` tasks; removing a failed task also discards its retry capability. By default, `task clean` removes `completed` and `canceled` tasks only; it does not remove `failed` tasks because failed tasks may still be retryable. Removing failed tasks requires an explicit option such as `task clean --failed` or `task clean --all-inactive`, where all inactive means `completed`, `canceled`, and `failed`. Removal/cleanup must not remove active tasks such as queued/running/paused tasks; callers must cancel them first. After removal or cleanup, `task get` returns `task.not_found` for the removed task, and associated in-memory sealed action plan/Lua object may be released. The retry method/interface still exists, but retrying a completed or canceled task must immediately return an error and must not resurrect the task or create a replacement task. If the user wants a fresh task object after completion/cancellation, they must use the original task creation/submission path again. Pause/resume are task-level APIs, not download-executor-only public APIs, but whether they are currently allowed is phase/executor-specific and process-specific. Some phases, including `waiting_user`, are state snapshots rather than pausable processes, so they must expose `pause = false` and `resume = false` even while the task status remains `running`. `task resume` is valid only for a `paused` task whose current executor/phase supports resume; calling resume for `running`, `waiting_user`, `queued`, `failed`, `completed`, or `canceled` tasks returns an unsupported-control error and does not mutate state. Failed tasks use retry rather than resume. The first implementation must support pause/resume for download-phase tasks; other phases can expose false capabilities. Task snapshots include current capability flags for these controls so Flutter does not infer executor-specific behavior from status/phase; a capability may be false for the current task/phase, but the API/control model exists. Calling an unsupported control for the current state/phase, such as pausing a `waiting_user` phase or resuming a non-paused task, returns an explicit error such as `task.pause_not_supported`, `task.resume_not_supported`, `task.retry_not_supported`, or `task.cancel_not_supported` and must not mutate task state; unsupported controls are never silent no-ops. Task snapshots include at most the current diagnostic summary needed for UI display, not a diagnostic history or event log; detailed diagnostics/logs use separate query operations. Every field included in a task snapshot, including capabilities and current diagnostic summary, must also be obtainable by active query operations or a combination of query operations within the current runtime process; the callback exists to reduce getter/UI polling pressure, not to become the only way to learn state. If UI/CLI explicitly wants authoritative task state, it calls separate getter operations that query/recompute current internal task state, including at least single-task lookup and task listing/summary for active tasks or package-scoped tasks. Runtime notifications must not be inflated into a log/cursor/replay system merely to answer current-state queries. + +This is not the same as Android system notifications. Android notification/foreground-service behavior remains a later platform side-effect decision. + +Downloads and installers may be mock side-effect executors in the first implementation, but their task/runtime callbacks should be shaped like future product notifications. + +## Deferred to later ADRs + +This ADR intentionally accepts the first runtime/task/notification architecture while leaving real platform side effects for later design work: + +- live HTTP/provider execution as product default; +- real Android download/background worker semantics; +- Android PackageInstaller/intent/URI/SAF/FileProvider/Shizuku/root installer execution semantics; +- Android system notification/foreground-service policy; +- multi-engine or multi-isolate runtime notification sharing; +- batch-update parent/child task APIs, if ever needed. + +The accepted boundary remains: no Flutter-owned provider selection, version comparison, package metadata caching, download task state machine, or installer semantics. diff --git a/docs/architecture/target-architecture.md b/docs/architecture/target-architecture.md new file mode 100644 index 000000000..ea4268475 --- /dev/null +++ b/docs/architecture/target-architecture.md @@ -0,0 +1,143 @@ +# Target Architecture + +Date: 2026-06-20 + +## Source basis + +This document is based on the copied root 2026-06-20 rewrite plan at `docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md`, the synced repository state, current code inspection, Cucumber documentation lookup, and the user's clarified testing rule. + +Canonical plan hash: + +- SHA-256: `a9d02ce7fb88112506580a6e5e723494016ff75cc950083f66ab93701bbc3a0a` +- Copied from `xz@100.65.231.22:/home/xz/.hermes/plans/2026-06-20_181038-upgradeall-flutter-getter-rewrite-complete-plan.md` +- Matches the plan captured in the pre-sync stash untracked parent. + +> All user-facing functions/interfaces need BDD Cucumber coverage. The main user-facing surfaces are the UpgradeAll App and Getter CLI. Internal interfaces use unit/integration/traditional tests because BDD fits integration behavior better than algorithm-level unit tests. + +## Exact repository baseline + +Superproject: + +- Branch used for planning: `refactor/phase0-planning-20260620` +- Synced upstream branch: `master` / `origin/master` +- Baseline commit: `4a1aae1d44a418989b0d3d28528cacff0cc066c0` +- Baseline commit subject: `feat: hub authentication UI with auth_keywords support` +- Pre-sync local backup branch: `backup/pre-sync-master-20260620-183445` at `8a820a76bfee22228272912e4e10127b63284583` + +Getter submodule: + +- Path: `core-getter/src/main/rust/getter` +- Baseline commit: `f011d9b4b9a15f83cd39c86e781ad8830a8ecae6` +- Baseline subject: `feat: add auth_keywords to HubItem and manager_update_hub_auth RPC` +- Pre-sync submodule backup branch: `backup/pre-sync-20260620-183445` at `73a5fc921ef4644346f8b984ac4f10394b7ba291` + +Stash backups: + +- Superproject WIP backup: `stash@{1}` / `b9462fb0c8f15b1ffddd2cd36125e21e2a4b9a09`, message `backup before 2026-06-20 refactor planning 20260620-183445 (superproject)` +- Submodule WIP backup: `core-getter/src/main/rust/getter` `stash@{0}` / `ac6c76288d069b047a784df6aceb82536e870e49`, message `backup before 2026-06-20 refactor planning 20260620-183445 (submodule getter)` +- Agent artifact backup: `stash@{0}` / `7d668a1e0514972c23911f29ec11b08763db222a` in the superproject, message `agent artifacts after refactor planning context 20260620-185313` + +Current Android app identity: + +- `applicationId`: `net.xzos.upgradeall` +- `namespace`: `net.xzos.upgradeall` +- `versionCode`: `105` +- `versionName`: `0.20-alpha.4` +- `compileSdk`: `36` +- `targetSdk`: `36` +- `minSdk`: `23` + +Current module graph: + +- `:app` +- `:core` +- `:core-websdk` +- `:core-utils` +- `:core-shell` +- `:core-downloader` +- `:core-installer` +- `:core-android-utils` +- `:app-backup` +- `:core-getter` +- `:core-websdk:data` +- `:core-getter:provider` +- `:core-getter:rpc` + +Current build facts: + +- Gradle wrapper: `9.3.1` +- AGP: `9.0.1` +- Kotlin: `2.3.10` +- Android Rust Gradle plugin: `0.6.0` +- Java/Kotlin toolchain: `21` +- `core-getter` builds Rust `api_proxy` for Android ABIs through the Android Rust Gradle plugin. +- Top-level Gradle configuration runs Cargo metadata for `core-getter/src/main/rust/api_proxy/Cargo.toml`; breaking Cargo metadata can break Gradle configuration before tests run. + +Current getter facts: + +- The Rust getter crate already has `src/lib.rs` and `src/main.rs`. +- `src/main.rs` currently only prints `Hello, world!`, so the CLI exists structurally but not as a supported interface. +- Existing Rust tests use traditional Rust test tooling and fixtures; no Cucumber/Gherkin dependency is present yet. + +## Target runtime layers + +1. **Getter Core** owns product behavior and durable state. +2. **Getter Library** exposes the embeddable engine contract used by app/platform adapters. +3. **Getter CLI** exposes the command-line user interface for automation, diagnostics, and AI/operator workflows. +4. **UpgradeAll App** is the graphical shell and platform integration layer. +5. **Legacy Migrator** preserves supported Android user data during official upgrade. +6. **Source-level page modules** provide downstream UI customization through typed contracts and stable test IDs. + +## Testing architecture + +Testing is layered by audience and feedback speed: + +- **BDD Cucumber/Gherkin acceptance tests**: required for user-facing UpgradeAll App behavior and Getter CLI behavior. +- **UI/widget tests**: required for page states, stable IDs, and rendering contracts. +- **Getter traditional tests**: required for algorithms, parsers, provider behavior, storage, migration, download orchestration, and library contracts. +- **Migration tests**: required before Android release, including success and failure recovery paths. +- **Black-box UI flows**: required for primary app flows using stable semantic/test IDs; these may be generated from or mapped to Gherkin scenarios. + +## Phase gates + +### Phase 0: Planning and verification skeleton + +- Record glossary and ADRs. +- Create a single verification entrypoint. +- Do not revive stashed implementation work as accepted architecture. +- Do not implement product behavior before the first failing test is defined. + +### Phase 1: Getter workspace and API seams + +- Split getter by domain boundaries only after ADRs are accepted. +- Preserve Cargo metadata compatibility for Gradle during transitions. +- Define library and CLI contracts before filling behavior. + +### Phase 2: Storage and migration foundation + +- Implement Rust-managed SQLite behind getter tests. +- Create legacy import fixtures and failure semantics. + +### Phase 3: CLI-first behavior slices + +- Use Getter CLI Cucumber scenarios to drive headless product behavior. +- Reuse the same core behavior from library and CLI. + +### Phase 4: Flutter app shell and UI BDD + +- Build UI around getter contracts. +- Every public route/action/state receives stable IDs. +- App behavior scenarios drive integration tests. + +### Phase 5: Android migration release readiness + +- End-to-end migration tests on supported legacy states. +- Official Android identity preserved for direct upgrade. +- Recovery/reporting behavior verified. + +## Non-goals for Phase 0 + +- No production code rewrite. +- No choice to delete `:core-getter:rpc` unless an ADR explicitly replaces that boundary. +- No assumption that the stashed direct-JNI work is the approved direction. +- No Flutter screen implementation before getter contracts and behavior tests exist. diff --git a/docs/architecture/upgradeall-getter-rewrite-wiki.md b/docs/architecture/upgradeall-getter-rewrite-wiki.md new file mode 100644 index 000000000..9b8569268 --- /dev/null +++ b/docs/architecture/upgradeall-getter-rewrite-wiki.md @@ -0,0 +1,1218 @@ +# UpgradeAll getter 重构架构设计 Wiki + +> 状态:设计草案 / living document +> 日期:2026-06-21 17:27 CST +> 适用范围:UpgradeAll 从旧 Android/Kotlin + Room + hub-app 模型,重构为 Flutter UI + Rust getter core + Lua package repository 模型。 +> 设计原则:所有重要代码边界、数据模型、迁移策略和架构决策都必须记录在案;后续实现必须同步更新本文或对应 ADR。 + +--- + +## 0. 文档目的 + +本文是 UpgradeAll 新架构的主设计文档,用来约束后续代码实现、重构计划、迁移策略和 wiki/开发文档。 + +本文不是单纯的想法记录,而是用于回答这些问题: + +1. 为什么放弃旧的 `hub-app` 模型。 +2. 新的 `getter` 和 `APP` 边界是什么。 +3. 为什么所有 product/domain logic 都进入 Rust getter。 +4. 为什么新 UI 使用 Flutter。 +5. 为什么 getter backend storage 使用 SQLite。 +6. 为什么 package/update 模型采用 Lua package repository,而不是固定模板或旧 Hub。 +7. Lua package 脚本如何组织、导入、覆写、生成和校验。 +8. 旧数据如何无感迁移。 +9. 用户二次开发、AI fork、patch stack 如何不被架构拖累。 +10. 哪些决策已经锁定,哪些仍是 open question。 + +后续规则: + +- 每个重要代码模块都应能在本文或后续 ADR 中找到设计依据。 +- 每个破坏性决策都应有「为什么不选其他方案」。 +- 每个迁移逻辑都应记录数据来源、目标、保留字段和丢弃字段。 +- 每个 Lua API / Rust API / Flutter adapter API 都应有边界说明。 + +--- + +## 1. 背景:现有 UpgradeAll 的事实基础 + +### 1.1 当前产品定位 + +当前 UpgradeAll 是 Android 上的更新检查/下载工具,核心能力包括: + +- 检查 Android apps、Magisk modules 等对象的更新。 +- 从多个来源获取 release/update 信息,例如 GitHub、GitLab、F-Droid、Google Play、CoolApk、Source List / cloud config。 +- 支持用户自定义规则、Hub/App 配置、外部下载器、本地/云备份、日志、安装器等能力。 + +代码审计来源: + +- `/home/xz/workspace/upgradeall-audit/upgradeall-current-context-map.md` +- `settings.gradle` +- `app/build.gradle` +- `app/src/main/AndroidManifest.xml` +- `core/src/main/java/net/xzos/upgradeall/core/database/MetaDatabase.kt` +- `core-getter/rpc/src/main/java/net/xzos/upgradeall/getter/rpc/GetterService.kt` + +关键事实: + +- 官方 Android applicationId 是 `net.xzos.upgradeall`。 +- 当前版本信息:`versionCode = 105`, `versionName = "0.20-alpha.4"`。 +- Debug build 使用 `applicationIdSuffix ".debug"`,不能代表正式升级路径。 +- 当前 app 仍以 Activity / Fragment / XML / DataBinding / ViewBinding 为主。 +- Compose 依赖存在,但不是主 UI 架构。 +- `core-getter` 已经有 Rust getter 的 JNI/RPC 集成,但目前仍是过渡形态。 + +Rewrite 决策更新:`app_flutter/` 是新架构唯一产品 APK 入口;旧 `:app` 原生 UI 暂时保留为参考代码,但不再作为 rewrite 的发布/启动路径。Android CI/release 产物必须来自 Flutter app,旧 native UI 不能继续接收新的产品入口。 + +### 1.2 当前 Gradle 模块 + +现有模块: + +```text +:app +:core +:core-websdk +:core-utils +:core-shell +:core-downloader +:core-installer +:core-android-utils +:app-backup +:core-getter +:core-websdk:data +:core-getter:provider +:core-getter:rpc +``` + +当前职责概括: + +- `:app`:Android UI、Activity/Fragment、WorkManager、偏好设置、日志、文件管理等。 +- `:core`:Room DB、App/Hub/domain 状态、版本比较、更新状态推导、manager 薄壳。 +- `:core-websdk`:旧 Web SDK API 与 Rust getter 代理桥接;Kotlin hub RPC server;GooglePlay/CoolApk 回调。 +- `:core-downloader`:下载相关 Android/Kotlin 层能力。 +- `:core-installer`:安装器相关能力。 +- `:core-android-utils`:PackageManager / Android 文件与系统工具。 +- `:app-backup`:本地 zip 备份/恢复与 WebDAV 云备份。 +- `:core-getter`:JNI/native Rust api_proxy + GetterPort。 +- `:core-getter:rpc`:Kotlin WebSocket JSON-RPC client 和 DTO。 + +### 1.3 当前用户可见功能 + +新架构必须理解并有意识地处理这些现有功能: + +- Home:模块入口、检查更新、自动检查更新、更新数量展示、普通/简化模式。 +- Apps/Magisk:按 app type 展示,包含 Updates/Star/All/Applications 条件 tab,支持添加、编辑、删除、批量更新/忽略。 +- Discover:发现 cloud config/source list 中的 app 配置,搜索、刷新、导入。 +- Hub Manager:启用/禁用 Hub、applications mode、认证、URL replace、全局设置。 +- App Detail:版本选择、查看 changelog/more URL、下载 asset、编辑 App、改 source/Hub 优先级、忽略当前版本。 +- File Management:下载任务状态、暂停/继续/重试/删除/安装/打开文件。 +- Settings:Backup、Downloader、UI、Updates、Language、Installation。 +- Log:分类查看、清空、导出。 +- Restore/Migration:恢复/迁移进度页。 + +这些功能不一定一比一保留旧 UI,但产品语义必须被新架构覆盖或明确标记为 v1 非目标。 + +--- + +## 2. 旧架构的问题 + +### 2.1 `hub-app` 模型已经不够 + +旧模型大致是: + +```text +App + app_id + enable_hub_list + cloud_config + +Hub + GitHub / F-Droid / GooglePlay / CoolApk / Source List +``` + +这个模型的问题: + +1. GitHub/F-Droid/Google Play/CoolApk 本质上不是「包」,而是 provider/source/backend。 +2. 同一个 App 可以来自多个来源,但它仍应是同一个更新对象。 +3. 不同项目的发布方式差异极大,固定 Hub 模板会无限膨胀。 +4. App 的打包、版本选择、asset 选择、校验、安装对象匹配都应是 package 级别逻辑,而不是 Hub 级别逻辑。 +5. 旧模型难以表达类似 package manager 的 repository/overlay/override 关系。 + +结论:新架构放弃 `hub-app` 模型,改为 app/package-centric 模型。 + +### 2.2 渐进式剥离失败 + +当前代码已经尝试将部分逻辑迁移到 Rust getter,但仍存在: + +- Room 与 Rust JSONL 并存。 +- Kotlin AppManager/HubManager 仍承担大量状态/业务逻辑。 +- `migrateRoomToRust()` 是一次性倒账,不是正式迁移系统。 +- 旧 UI、旧 DB、旧 Hub、Rust getter 的边界复杂交错。 +- 兴趣开发无法长期维持这种双架构过渡成本。 + +结论:新版本从零重构,不继续渐进式剥离。 + +### 2.3 当前 Room -> Rust 迁移技术债 + +当前 `migrateRoomToRust()` 的问题: + +- 只在 `apps.jsonl` 不存在或为空时执行。 +- 从 Room 读取 apps/hubs/extra_hub。 +- 没有覆盖 `extra_app`。 +- AppEntity 迁移时 Rust 会重新分配 app UUID。 +- 没有持续同步或双向同步。 +- 它是启动时一次性倒账,不是版本化、事务性、可验证的正式迁移。 + +结论:正式重构不能沿用该方案。 + +--- + +## 3. 新架构总览 + +### 3.1 核心决策 + +已锁定决策: + +1. 新 UI 使用 Flutter。 +2. getter 使用 Rust。 +3. 所有 product/domain logic 都放在 getter。 +4. Android App 只是 Flutter UI + platform adapter。 +5. App 内 getter 形态采用嵌入式 Rust library / FFI 风格,不以 daemon 作为主路径。 +6. 平台专用 API 通过 Rust-active platform adapter 暴露给 getter/native bridge;Rust 定义接口并主动调用 Android 实现,Android/Kotlin 只提供平台事实。 +7. 后端存储使用 SQLite。 +8. 用户通过非标准方式改坏 backend storage 时,getter fail fast 报错,不提供复杂恢复引导。 +9. 用户二次开发采用 patch stack/source fork 模式,不设计复杂 runtime customization/plugin 系统。 +10. 旧数据迁移必须对普通用户无感自动完成,同时可提供手动导入。 + +### 3.2 顶层结构 + +目标结构: + +```text +Flutter APP + - UI rendering + - navigation + - Android permission prompts + - user confirmation flows + - render getter/platform DTOs + | + | FFI / native bridge boundary + v +Rust getter core + native bridge + - Rust-active platform adapter interface + - Android PackageManager inventory calls through platform adapter + - installer adapter handoff + - notification adapter handoff + - SAF/file picker/URI permission handoff + - app/package model + - repository/overlay resolution + - Lua package evaluation + - update discovery/select/resolve + - provider/source backends + - download task state machine + - SQLite main DB + - cache DB + - legacy migration + - CLI API +``` + +### 3.3 Getter 必须拥有的能力 + +必须进入 getter core: + +- App/package identity。 +- Repository/overlay 管理。 +- Lua package loading/evaluation。 +- Package update lifecycle。 +- Provider/source backend。 +- Version parsing/comparison/filtering。 +- Release/artifact normalization。 +- Update status calculation。 +- Download request/action generation。 +- Download task state machine。 +- Main SQLite storage。 +- Cache DB。 +- Legacy migration/import。 +- Event stream。 +- CLI API。 +- Diagnostics/error reporting。 + +### 3.4 APP/platform adapter 保留的能力 + +保留在 Flutter/Android adapter: + +- Android PackageManager installed app scanning exposed as raw facts through the Rust-active platform adapter (ADR-0009)。 +- Android installed version lookup。 +- APK install / package installer / Shizuku/root installer。 +- Android permission request。 +- Notification / foreground service integration。 +- SAF/file picker/URI permission。 +- Activity/UI navigation。 +- Android-specific file opening intents。 +- Theme/localization/user-facing UI preferences。 + +--- + +## 4. Package-centric 模型 + +### 4.1 Package ID + +Package 主 ID 使用 UpgradeAll 自己的可读 namespace,不使用 UUID 作为主身份。 + +示例: + +```text +android/org.fdroid.fdroid +android/com.termux +magisk/zygisk-next +generic/example-tool +``` + +设计理由: + +- UUID 对用户无意义。 +- package ID 应可读、可 diff、可手写、可在 issue/文档中引用。 +- Android 和 Magisk 迁移可以自然映射。 + +旧数据映射: + +- 旧 Android app:`android/`。 +- 旧 Magisk module:`magisk/`。 + +### 4.2 APP/package-centric,而不是 hub-centric + +用户界面和 getter 的用户可见概念应围绕 App/package,而不是 Hub。 + +旧 Hub 的概念拆分为: + +- repository:一组 package Lua 文件和 reusable modules。 +- provider/source:GitHub、F-Droid、Google Play、CoolApk 等访问后端。 +- package:一个可维护更新单元。 +- installed target:本机安装对象,如 Android package 或 Magisk module。 +- user state:enabled、ignore、source priority、favorite、overrides 等用户状态。 + +CLI/UI 命名建议: + +- UI:Apps / Modules / Repositories / Sources。 +- CLI:可以使用 `getter app ...` 面向用户。 +- Rust 内部:使用 `Package` / `ResolvedPackage`。 + +### 4.3 多来源同一 package + +同一个 Android App 如果可来自 F-Droid、GitHub、Google Play,它应是同一个 package 的多个 source/provider,而不是多个 package。 + +例如: + +```lua +return android_app { + id = "android/org.fdroid.fdroid", + name = "F-Droid", + installed = android.package("org.fdroid.fdroid"), + sources = { + fdroid.package { package = "org.fdroid.fdroid" }, + github.release { repo = "f-droid/fdroidclient" }, + }, +} +``` + +source priority 可以来自 package 默认值,也可以被 user state 覆盖。 + +--- + +## 5. Repository / overlay 模型 + +### 5.1 Repository 类型 + +新架构使用 repository/overlay 模型,参考 Portage/emerge 的 overlay 思路。 + +Repository 可以是: + +- official:官方包定义仓库。 +- community:社区包定义仓库。 +- local_autogen:自动生成的本地包仓库。 +- local:用户手写/覆盖仓库。 + +### 5.2 Priority 规则 + +优先级规则: + +- 数字越大优先级越高。 +- getter resolved view 只看最高优先级 package。 +- 用户可以手动修改 repo priority。 + +默认建议: + +```text +local 100 用户手写覆盖,默认最高 +official 0 官方仓库 +community 0 或用户配置 +local_autogen -1 根据已安装应用自动生成的 fallback +``` + +注意:`local` 只是默认最高,用户可以自己改优先级。 + +### 5.3 local 与 local_autogen 的区别 + +`local`: + +- 用户手写/编辑。 +- 用于明确覆盖上游 package。 +- 默认 priority 最高。 +- 普通清理按钮不应删除 `local`。 + +`local_autogen`: + +- 用户点击“从已安装应用生成”后产生。 +- 是低优先级 fallback。 +- 上游 official package 出现后,official 会覆盖它。 +- 清理按钮只作用于该 autogen 仓库。 + +仓库名固定为 `local_autogen`。它表达“本地自动生成的 fallback 仓库”。 + +### 5.4 首次旧数据迁移与 autogen 的区别 + +旧数据迁移是特殊情况: + +- 首启迁移必须无感。 +- 迁移可以一次性生成 `local` package 文件,以保留用户旧配置。 +- 该行为只发生一次。 + +普通 installed autogen: + +- 是用户主动点击按钮触发。 +- 生成到 `local_autogen`。 +- 不是首启迁移的一部分。 + +--- + +## 6. Repository 文件布局 + +建议 layout: + +```text +repo/ + repo.toml + + packages/ + android/ + org.fdroid.fdroid.lua + com.termux.lua + magisk/ + zygisk-next.lua + + lib/ + std.lua + github.lua + fdroid.lua + google_play.lua + coolapk.lua + android.lua + magisk.lua + github_android_apk.lua + fdroid_android_apk.lua + + templates/ + android_installed_app.lua + magisk_installed_module.lua + github_android_apk.lua + fdroid_android_apk.lua +``` + +`repo.toml` 示例: + +```toml +id = "official" +name = "UpgradeAll Official" +priority = 0 +api_version = "getter.repo.v1" +``` + +### 6.1 packages/ + +`packages/` 里是最终被 getter 解析的 package Lua 文件。 + +路径建议: + +```text +packages/android/org.fdroid.fdroid.lua +packages/magisk/zygisk-next.lua +``` + +路径可推导 package id: + +```text +packages/android/org.fdroid.fdroid.lua -> android/org.fdroid.fdroid +``` + +文件内也应声明同样 id,getter 校验路径 id 和声明 id 一致。 + +### 6.2 lib/ + +`lib/` 里是 reusable Lua module。 + +注意:这里的角色类似 Gentoo eclass,但项目语法里不需要真的叫 eclass。 + +原则: + +- 不限定 lib 里写什么。 +- 只抽象重复代码。 +- 可以提供高层 helper,例如 `github_android_apk { ... }`。 +- package 文件通过 Lua 原生 `require()` 导入。 + +示例: + +```lua +local github_android = require("lib.github_android_apk") +``` + +### 6.3 templates/ + +`templates/` 里是 Lua 生成器,用于生成新的 package Lua 文件内容。 + +这参考 Funtoo Metatools/autogen: + +- Funtoo metatools 用 autogen.py/autogen.yaml 查询 upstream 并生成 ebuild。 +- UpgradeAll 的 templates 用 Lua 根据 installed inventory 或用户输入生成 package Lua。 + +template 直接返回文件路径和文本内容,而不是返回 AST。 + +示例: + +```lua +return template { + id = "android_installed_app", + + generate = function(ctx, input) + return { + path = "packages/android/" .. input.package_name .. ".lua", + content = [[ +local android = require("lib.android") + +return android.local_app { + id = "android/]] .. input.package_name .. [[", + name = "]] .. input.label .. [[", + package_name = "]] .. input.package_name .. [[", +} +]] + } + end +} +``` + +--- + +## 7. Lua package API + +### 7.1 语言选择 + +内嵌语言:Lua。 + +优先实现:`mlua`。 + +理由: + +- Rust 集成成熟。 +- 语言小,适合作为嵌入式脚本。 +- 支持 metatable,可实现继承/override/object helper。 +- 适合 ebuild/eclass-like 的可编程 package definition。 +- AI 和用户都比较容易读写。 + +### 7.2 不发明自定义语法 + +原则: + +- 尽可能使用 Lua 原生语法。 +- 不维护复杂自定义语法。 +- 不引入新的 DSL parser。 +- package override/object 行为用 Lua table/metatable/helper 实现。 + +### 7.3 Parent package import + +父包导入使用 host helper: + +```lua +local base = package_from("official", "android/org.fdroid.fdroid") +``` + +理由: + +- package id 里有 `/`、`.`、`-` 等字符。 +- Lua 原生 `require()` 会把 `.` 当模块路径分隔。 +- parent package import 需要显式 repo id,避免 priority/递归歧义。 +- 这是 host function,不是新语法。 + +Reusable module 仍使用 Lua `require()`: + +```lua +local github = require("lib.github") +``` + +### 7.4 Lua/Rust boundary / Lua/Rust 边界 + +Lua package scripts 在边界返回 JSON-like object/table。 + +原则: + +- Lua↔Rust crossing 视为 RPC/serialization boundary。 +- Lua 返回 plain data。 +- Rust validate/deserialize 成 typed structs。 +- 如果 mlua 能直接把 Lua table 映射到 Rust struct,可以作为实现细节。 +- 概念上不暴露可变 Rust domain object 给 Lua。 + +好处: + +- Lua API 简单。 +- cache/debug 输出可检查。 +- 不绑定 Rust 内部对象生命周期。 +- 错误模型清晰。 + +错误分层: + +1. Lua runtime error:脚本执行失败。 +2. Schema validation error:Lua 返回 table,但字段不符合 schema。 +3. Domain error:schema 合法,但语义不成立。 + +### 7.5 Package 文件示例 + +官方 package: + +```lua +local github_android = require("lib.github_android_apk") + +return github_android { + id = "android/org.fdroid.fdroid", + name = "F-Droid", + android_package = "org.fdroid.fdroid", + repo = "f-droid/fdroidclient", + asset_pattern = "%.apk$", +} +``` + +本地 override: + +```lua +local base = package_from("official", "android/org.fdroid.fdroid") + +return base:override(function(pkg) + pkg.name = "F-Droid Custom" + pkg.source_priority = { "github", "fdroid" } + + local parent_select = pkg.select + function pkg:select(ctx, candidates, installed, user_state) + local selected = parent_select(self, ctx, candidates, installed, user_state) + selected.channel = "custom" + return selected + end +end) +``` + +--- + +## 8. Override API + +### 8.1 为什么需要 override helper + +用户如果想修改上游 package,不应复制整个上游文件。 + +目标: + +- 用户可以引用父包。 +- 用户只改需要改的字段或 hook。 +- 上游更新时,用户 patch 尽量不冲突。 + +### 8.2 Table override + +适合简单字段替换: + +```lua +local base = package_from("official", "android/org.fdroid.fdroid") + +return base:override { + name = "F-Droid Custom", + source_priority = { "github", "fdroid" }, +} +``` + +语义: + +- getter/lib 克隆 base package。 +- 表中出现的字段替换父字段。 +- 简单、直观。 +- 不适合复杂函数覆写。 + +### 8.3 Function override + +适合复杂逻辑: + +```lua +local base = package_from("official", "android/org.fdroid.fdroid") + +return base:override(function(pkg) + pkg.name = "F-Droid Custom" + + local parent_select = pkg.select + function pkg:select(ctx, candidates, installed, user_state) + local selected = parent_select(self, ctx, candidates, installed, user_state) + selected.channel = "custom" + return selected + end +end) +``` + +语义: + +- getter/lib 克隆 base package。 +- 用户函数修改 clone。 +- 可以替换字段,也可以替换 hook。 +- 可以调用父函数。 + +### 8.4 推荐策略 + +建议同时支持 table override 和 function override。 + +文档推荐: + +- 简单 metadata 修改用 table override。 +- 非平凡修改用 function override。 + +注意:override helper 是 Lua lib/helper 问题,不是 Rust API 问题。Rust 只关心最终返回的 JSON-like package object 是否符合 schema。 + +--- + +## 9. Package lifecycle phases + +### 9.1 参考 emerge,但不照搬 + +Gentoo ebuild phase 包括: + +```text +pkg_pretend +pkg_setup +src_unpack +src_prepare +src_configure +src_compile +src_test +src_install +pkg_preinst +pkg_postinst +``` + +UpgradeAll 不是源码编译系统,因此不复制 `src_compile/src_install` 这些名字。 + +参考点是: + +- package 文件提供一组生命周期 hook。 +- 默认 hook 由 reusable module 提供。 +- package 可以 override hook。 +- getter 按固定顺序执行。 + +### 9.2 新 phase 名称 + +采用 app-centric 命名: + +```text +preflight +setup +match +discover +prepare +select + +post_update +``` + +`plan` 这个名字过于模糊,已拒绝。 + +推荐替代: + +- `resolve`:把 selected candidate 解析成可执行 actions。 +- `make_actions`:更直白,返回 action list。 + +目前建议:`resolve`。 + +### 9.3 Phase 语义 + +#### preflight(ctx) + +用途: + +- 预检查。 +- 检查平台是否支持。 +- 检查权限声明。 +- 检查 provider/backend 可用性。 +- 检查明显不兼容的 user state。 + +参考 Gentoo:`pkg_pretend`。 + +#### setup(ctx) + +用途: + +- 初始化 package evaluation。 +- 解析 provider config。 +- 检查 auth 是否存在。 +- 确定默认 source priority。 + +参考 Gentoo:`pkg_setup`。 + +#### match(ctx, installed_item) + +用途: + +- 判断一个 installed inventory item 是否匹配本 package。 +- 替代旧 `checkAppAvailable` 的一部分语义。 + +#### discover(ctx) + +用途: + +- 查询 provider/source。 +- 返回 release candidates。 + +替代旧: + +- `getAppReleaseList` +- `getAppUpdate` + +#### prepare(ctx, candidates) + +用途: + +- 将 provider-specific release 规范化为 canonical candidates。 +- 过滤 prerelease。 +- 过滤 arch/variant。 +- 提取/规范化 version。 +- 处理 changelog/asset metadata。 + +参考 Gentoo:`src_prepare`。 + +#### select(ctx, candidates, installed, user_state) + +用途: + +- 从 candidates 中选择应更新的版本和 artifact。 +- 应用 version compare。 +- 应用 ignore/pin/source priority。 + +#### resolve(ctx, selected) + +用途: + +- 将 selected candidate 转成可执行动作。 +- 返回 DownloadRequest / InstallAction / warnings。 + +示例输出: + +```lua +return { + actions = { + { + type = "download", + url = selected.artifact.url, + file_name = selected.artifact.name, + headers = {}, + }, + { + type = "install", + installer = "android_package", + file = selected.artifact.name, + }, + }, + warnings = {}, +} +``` + +#### post_update(ctx, result) + +用途: + +- 可选的更新后 message / metadata。 +- 应尽量少用。 +- 大部分状态变更应由 Rust core 处理。 + +--- + +## 10. Permissions / network model + +### 10.1 默认无直接网络 + +默认情况下,Lua package script 不获得直接网络 API。 + +它可以通过 getter 暴露的 provider/source API 间接获取 release 信息。 + +### 10.2 自由网络权限 + +如果 package 声明自由网络权限,getter 才向 Lua 环境暴露直接网络接口。 + +该权限用于类似 live/9999 包或特殊 upstream 逻辑。 + +UI 行为: + +- 在 App detail 的 source/version 层显示黄色 warning tag。 +- 该 tag 只提示,不阻止使用。 + +### 10.3 不做脚本超时 + +不对 Lua 脚本本身设置 runtime timeout/fuel limit。 + +理由: + +- 停机问题无法一般解决。 +- 脚本速度受本地机器、网络、provider 等影响。 +- 网络操作使用正常 network timeout。 + +### 10.4 v1 暂不强制校验 + +v1 暂不做 repo/script/artifact 强校验。 + +理由: + +- 先信任 Git 仓库。 +- 校验系统会显著增加复杂度。 +- 可以先保留 schema 字段,后续再 enforce。 + +--- + +## 11. Storage model + +### 11.1 Main SQLite DB + +主 DB 存储权威用户状态和 getter 状态。 + +建议内容: + +- repositories registry。 +- repo priority。 +- enabled apps/packages。 +- user source priority override。 +- legacy ignore/mark version state mapped into `pin_version`. +- pins / version baselines。 +- favorites/star。 +- migration records。 +- settings。 +- credentials references。 +- later ADR-accepted operation-specific durable records; ADR-0011 keeps runtime task state process-memory only and excludes it from main/cache DB persistence。 + +### 11.2 Cache DB + +缓存 DB 单独文件,不与主 DB 混用。 + +缓存内容: + +- evaluated package metadata。 +- version/release candidates。 +- selected latest version。 +- asset metadata。 +- provider response cache。 +- search index。 +- validation result。 + +Cache key 应包含: + +```text +repo id +repo revision/hash +package file hash +Lua API version +getter version or package API version +platform target +permissions/network mode +``` + +### 11.3 Repo files + +package Lua source files 存在本地文件夹中。 + +SQLite 只记录 repo registry/path/revision/priority 等元信息。 + +Android 上 repo sync 可以先采用 archive zip/tar 或 bundled repo snapshot,避免直接依赖完整 git CLI。 + +--- + +## 12. URL rewrite / bashrc-like hooks + +旧 `extra_hub` 的 URL replace 语义保留,但改为全局策略。 + +要求: + +- 是全局的,不散落到每个 source。 +- 可按 package/repository scope 区分。 +- 参考 emerge bashrc 的精神:全局 hook 根据上下文做调整。 + +建议文件: + +```text +config/hooks/download_rewrite.lua +``` + +示例: + +```lua +return function(ctx, req) + if ctx.repo_id == "official" and ctx.package_id == "android/com.foo" then + req.url = req.url:gsub("https://github.com/", "https://mirror.example/github/") + end + + return req +end +``` + +执行阶段: + +- `resolve` 生成 DownloadRequest 后。 +- downloader submit 前。 + +--- + +## 13. Legacy migration + +### 13.1 迁移原则 + +旧数据迁移必须无感自动完成。 + +但迁移是有限/简单迁移,不追求完整复刻旧语义。 + +可以丢弃: + +- API key。 +- auth token。 +- 复杂 Hub 配置。 +- 无法可靠映射的特殊规则。 + +必须保留: + +- saved apps 的基本 identity。 +- Android package / Magisk module installed id。 +- legacy ignore version / mark version 能力映射为 `pin_version`,如果可映射。 +- user-visible tracked app 列表。 +- 常见 source/cloud config 能力,如果可内置转换。 + +### 13.2 迁移输入 + +旧 Room DB: + +- `app` +- `hub` +- `extra_app` +- `extra_hub` + +Room DB 信息: + +- name:`app_metadata_database.db` +- version:17 +- migrations:6->17 + +### 13.3 迁移输出 + +输出到: + +- getter main SQLite user state。 +- 必要时生成 `local` repo package Lua 文件。 + +迁移生成 `local` 是特殊情况,只做一次。 + +普通 installed autogen 不写 local,而写 `local_autogen`。 + +### 13.4 迁移匹配策略 + +建议流程: + +1. 使用 bundled official repo snapshot 做本地匹配,不依赖首启联网。 +2. 能匹配 official package 的旧 App:写入 user state,指向 official package。 +3. 不能匹配但常见类型可转换:生成 `local` package Lua。 +4. 稀有情况:迁移 installed id list,状态为 missing package,提示用户自己写或提交 issue。 +5. 迁移完成后记录 migration_runs。 + +### 13.5 迁移 UX + +- 普通用户无感进入新 App。 +- 迁移失败时进入 migration/recovery 页面。 +- 单个 package 无法匹配不应阻塞整个 App。 +- 该 package 显示 missing/needs package script 状态。 + +实现进展:Android/Flutter 侧已有 no-UI legacy migration adapter 负责定位、复制并 checkpoint 旧 Room SQLite triplet;Flutter 产品 APK 通过 slim getter/native bridge 调用 Rust `importLegacyRoomDatabase` / `legacyReportList`。Room 表读取、字段映射、migration record、tracked package 写入和 sanitized report 仍由 getter-owned Rust code 完成,Flutter/Kotlin 不解析 Room 行。 + +--- + +## 14. Installed autogen UX + +### 14.1 生成流程 + +用户点击“从已安装应用生成”: + +1. Flutter 调用 getter/native bridge 的 installed-autogen preview 操作。 +2. Rust platform adapter 主动调用 Android PackageManager adapter,取得 installed inventory 原始事实。 +3. getter 找出可生成的候选列表。 +4. UI 展示 getter-owned preview DTO。 +5. 用户 yes/no 确认。 +6. getter 写入 `local_autogen` repo。 +7. 生成后不会自动消失。 + +实现进展:Flutter 产品 APK 通过 `app_flutter/android/getter_bridge` 打包一个 slim native bridge library,包含 Rust `api_proxy`、`NativeLib` 和 Android installed-inventory facts provider。`api_proxy` 已提供 installed-autogen preview/apply JNI entrypoints;它们调用 Rust-active platform adapter 扫描 Android PackageManager 原始事实,再调用 getter-owned `getter-operations` 执行 `local_autogen` preview/apply。Flutter 已新增 installed-autogen 页面和 `MethodChannelGetterAdapter`,只渲染 getter-owned preview/apply DTO 并把用户接受的包 id 传回 getter;不能引入 Dart-led installed inventory scanner 或在 Dart/Kotlin 中生成 package id。 + +### 14.2 清理流程 + +用户点击“清除不存在的应用”: + +1. Flutter 调用 getter/native bridge 的 installed-autogen cleanup preview 操作。 +2. Rust platform adapter 主动调用 Android PackageManager adapter,取得当前 installed inventory 原始事实。 +3. getter 计算将删除列表。 +4. UI 展示 getter-owned preview DTO。 +5. 用户 yes/no 确认。 +6. getter 删除 `local_autogen` 中不再安装的记录/文件。 + +普通清理按钮只作用于 `local_autogen`,不删除 `local`。 + +--- + +## 15. Patch stack / user fork 模型 + +### 15.1 不设计复杂 runtime customization + +决策:用户二次开发采用 patch stack/source fork,不做复杂 runtime plugin/customization 框架。 + +原因: + +- 无法预测用户如何修改软件。 +- 为任意 customization 设计稳定 runtime API 会显著拖累兴趣项目维护。 +- Flutter 本身不是为了用户 runtime custom UI 设计的。 + +### 15.2 仍需降低 rebase 成本 + +参考 Linux kernel 的模块分离思想: + +- subsystem 目录清晰。 +- API 边界明确。 +- generated files 不手改。 +- 上游经常变的代码和用户常改代码尽量分离。 +- repository/package Lua 文件天然适合 patch stack。 + +### 15.3 稳定性承诺层级 + +建议承诺: + +- Rust internal API:不稳定。 +- Lua package boundary schema:相对稳定。 +- ResolvedPackage / UpdateCandidate / UpdateAction schema:稳定。 +- Platform RPC API:相对稳定。 +- CLI user-facing commands:稳定。 +- Individual package Lua scripts:可变。 + +--- + +## 16. Flutter APP 边界 + +Flutter APP 负责: + +- Home / App list / App detail / Settings / Log / Migration UI。 +- Android platform adapter。 +- 展示 getter 状态和事件。 +- 用户确认流程,如 autogen list yes/no、cleanup list yes/no。 +- 显示 free-network yellow tag。 + +Flutter APP 不负责: + +- provider/source logic。 +- package update selection。 +- version comparison。 +- storage migration。 +- download task state machine。 +- repository resolution。 +- Lua evaluation。 + +--- + +## 17. CLI 方向 + +getter CLI 应围绕 app/package,而不是 hub。 + +建议命令: + +```bash +getter app list +getter app show android/org.fdroid.fdroid +getter app check android/org.fdroid.fdroid +getter app update android/org.fdroid.fdroid +getter app sources android/org.fdroid.fdroid + +getter repo list +getter repo sync +getter repo eval official + +getter template list +getter template run android_installed_app --input ... + +getter storage validate +getter legacy migrate +``` + +CLI 是验证 getter core 独立性的关键: + +如果 CLI 无法完成核心更新流程,说明逻辑仍然泄漏在 Flutter/Android APP 里。 + +--- + +## 18. 非目标 + +v1 非目标: + +- 不做复杂 runtime UI customization framework。 +- 不做 Wasm plugin runtime。 +- 不做完整旧 auth/API key 迁移。 +- 不强制 repo/script/artifact 校验。 +- 不做 Lua script timeout/fuel limit。 +- 不保证任意用户 fork 不冲突。 +- 不继续维护旧 hub-app 逻辑模型。 + +--- + +## 19. Open questions + +仍需决策: + +1. `plan` 替代 phase 最终名字:`resolve` 还是 `make_actions`。 +2. template conflict policy:目标文件存在时 skip、overwrite、还是询问。 +4. repo priority 默认值精确设定。 +5. URL rewrite hook 的最终 Lua schema。 +6. Android repo sync v1 使用 bundled snapshot、zip/tar archive,还是 git/libgit2。 +7. main DB/cache DB 具体 schema。 +8. legacy migration 的字段级 mapping。 +9. Flutter UI route/page 具体信息架构。 +10. provider/source host API 细节。 + +--- + +## 20. Documentation policy + +从本文开始,UpgradeAll 重构文档采用以下规则: + +1. 每个重要架构决策写入 wiki 或 ADR。 +2. 每个新模块必须有 README 或 docs section,说明职责和非职责。 +3. 每个跨边界 API 必须有 schema 文档。 +4. 每个迁移步骤必须有 source/target mapping 文档。 +5. 每个 Lua host API 必须有示例。 +6. 每个用户可见破坏性行为必须有 UX 说明。 +7. 每次设计变更必须更新本文或后续 ADR。 + +推荐后续文档拆分: + +```text +docs/ + architecture/ + upgradeall-getter-rewrite-wiki.md + adr/ + 0001-app-centric-lua-package-repository-model.md + 0002-getter-flutter-platform-boundary.md + 0003-legacy-room-migration.md + 0004-sqlite-main-db-and-cache-db.md + 0005-lua-package-api.md + lua-api/ + package-lifecycle.md + repository-layout.md + templates.md + permissions.md + migration/ + legacy-room-mapping.md + app/ + flutter-ui-feature-parity.md +``` diff --git a/docs/implementation/coding-agent-handoff.md b/docs/implementation/coding-agent-handoff.md new file mode 100644 index 000000000..54c2cc01d --- /dev/null +++ b/docs/implementation/coding-agent-handoff.md @@ -0,0 +1,125 @@ +# Coding Agent Handoff: UpgradeAll Rewrite + +> Status: Ready for coding-agent bootstrap +> Date: 2026-06-21 +> Target agent: pi agent / coding agents running in the UpgradeAll repository + +## Read first + +Before coding, read these files in order: + +1. `AGENTS.md` +2. `docs/README.md` +3. `docs/architecture/upgradeall-getter-rewrite-wiki.md` +4. `docs/architecture/adr/0001-app-centric-lua-package-repository-model.md` +5. `docs/architecture/adr/0002-getter-flutter-platform-boundary.md` +6. `docs/architecture/adr/0003-legacy-room-migration.md` +7. `docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md` +8. `docs/architecture/adr/0005-lua-package-api.md` +9. `docs/architecture/adr/0006-package-centric-cli-command-contract.md` +10. `docs/architecture/adr/0007-flutter-getter-bridge-contract.md` +11. `docs/app/flutter-ui-feature-parity-and-testing.md` + +## Mission + +Rewrite UpgradeAll from scratch around: + +```text +Flutter APP + Rust getter core + Lua package repositories +``` + +The old hub-app model must not be reintroduced. + +## Non-negotiable architecture rules + +- Rust getter owns all product/domain logic. +- Flutter owns UI and platform adapter only. +- getter lives in the reusable `core-getter/src/main/rust/getter` git submodule (`https://github.com/DUpdateSystem/getter`); make getter changes in that submodule and update the superproject gitlink. +- getter storage uses SQLite main DB plus separate cache DB. +- Package definitions are Lua files in repositories. +- Lua returns JSON-like tables across the Lua/Rust boundary; Rust validates typed structs. +- Package IDs are readable, e.g. `android/org.fdroid.fdroid`, not UUID primary identities. +- Legacy Room migration must be automatic for normal users, but it is intentionally limited/simple. +- Patch stack/source fork is the supported customization model; do not design a runtime UI customization framework. + +## First implementation tranche + +Do not start with Flutter screens. + +Recommended order: + +1. Create Rust getter workspace skeleton. +2. Define core Rust types: + - PackageId + - RepositoryId + - RepositoryPriority + - ResolvedPackage + - InstalledTarget + - UpdateCandidate + - SelectedUpdate + - UpdateAction +3. Implement repository layout loader: + - `repo.toml` + - `packages/` + - `lib/` + - `templates/` +4. Integrate `mlua` minimally: + - load a Lua package file; + - expose `require` search path for repo `lib/`; + - expose `package_from(repo, id)` later; + - return JSON-like Lua table; + - validate into Rust structs. +5. Implement repository priority resolution. +6. Implement main DB and cache DB skeleton. +7. Write migration mapping tests before writing migration implementation. +8. Only after getter CLI can evaluate/list packages should Flutter shell begin. + +## Testing strategy + +Use mixed TDD and BDD. + +### TDD + +Use TDD for function/domain behavior: + +- PackageId parsing/formatting. +- Repository priority resolution. +- Lua table -> Rust validation. +- lifecycle phase output validation. +- cache invalidation key calculation. +- legacy Room mapping functions. +- version comparison and update selection. + +### BDD + +Use BDD for UI and integration behavior: + +- Flutter app list and app detail flows. +- installed autogen preview and confirmation. +- cleanup preview and confirmation. +- yellow network warning tag display. +- legacy migration success/warning UX. +- update/download task flow. + +BDD scenarios should be self-explaining documentation tests. Do not over-test BDD. + +## Documentation update rule + +If coding changes a boundary, model, phase, migration rule, repository layout, or testing rule, update docs in the same patch. + +Prefer adding/updating ADRs for decisions rather than burying major changes in code comments. + +## Repository naming + +- `local` is the default highest-priority user-authored override repository. +- `local_autogen` is the generated fallback repository used by ordinary installed-app autogen. +- Legacy migration is special and may generate `local` package files once for compatibility. +- Cleanup of missing generated apps only touches `local_autogen`. + +## Open questions to resolve before implementation hardens + +- Final name for the `resolve`/`make_actions` lifecycle phase. +- Template conflict behavior when generated target already exists. +- Concrete main DB/cache DB schema. +- Android repo sync mechanism: bundled snapshot vs archive download vs git/libgit2. +- URL rewrite hook schema. diff --git a/docs/lua-api/package-lifecycle.md b/docs/lua-api/package-lifecycle.md new file mode 100644 index 000000000..445637ecf --- /dev/null +++ b/docs/lua-api/package-lifecycle.md @@ -0,0 +1,136 @@ +# Lua Package Lifecycle + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +UpgradeAll uses an app/update lifecycle inspired by Gentoo ebuild phases, but does not copy source-build phase names. + +## Phases + +```text +preflight(ctx) +setup(ctx) +match(ctx, installed_item) +discover(ctx) +prepare(ctx, candidates) +select(ctx, candidates, installed, user_state) +resolve(ctx, selected) +post_update(ctx, result) +``` + +`resolve` is the current recommended replacement for the rejected name `plan`. It means: convert selected candidate/artifact into executable update actions. + +## preflight + +Validate whether the package can be evaluated on this platform and with current permissions/settings. + +## setup + +Resolve package/provider setup such as default source priority, credential availability and provider config. + +## match + +Match installed inventory items to this package. + +## discover + +Query sources/providers and return release candidates. + +## prepare + +Normalize, filter and enrich release candidates. + +## select + +Choose the candidate/artifact to update to, using installed version and user state. + +The first getter-core selection helper uses deterministic tokenized version comparison: digit runs compare numerically, text suffixes compare case-insensitively, separators are ignored, and a prerelease-like text suffix (for example `beta`/`rc`) sorts before the final release with the same numeric prefix. The selector returns the highest candidate newer than the effective local baseline. The effective baseline is normally the observed installed version; when the user has set `pin_version`, getter compares candidates against that pin override instead while still keeping the observed installed version available for display/diagnostics. + +## resolve + +Return executable update actions: + +```lua +return { + actions = { + { type = "download", url = "https://...", file_name = "app.apk" }, + { type = "install", installer = "android_package", file = "app.apk" }, + }, + warnings = {}, +} +``` + +As the first offline/mock-provider bridge toward this lifecycle, package Lua may also declare static `updates` candidates in the package table. Getter validates this table, routes it through a mock provider boundary (`StaticPackageUpdatesProvider`), performs Rust-owned selection/version comparison, and issues opaque runtime `action_id`s from the selected candidate; Flutter must still return only the getter-issued `action_id` and must not assemble download/install action payloads. + +```lua +return package_def { + id = "android/org.fdroid.fdroid", + name = "F-Droid", + updates = { + { + version = "1.2.0", + channel = "stable", + source = "fixture", + artifacts = { + { + name = "app.apk", + url = "https://example.invalid/app.apk", + file_name = "fdroid.apk", + }, + }, + }, + }, +} +``` + +The first Phase D implementation exposes offline update checks through both a normalized CLI fixture command (`getter --data-dir update check --fixture `) and registered-package native/runtime action issuance over static Lua `updates`. These are mock-provider paths, not live provider output. They return `network_required = false`, update-check status, selected candidate/artifact, and getter-owned action issuance data. They do not execute network providers, download files, persist download tasks, stream progress events, or invoke Android installers. + +ADR-0011 supersedes the earlier persisted fake task scaffold. The accepted Phase D runtime consumes getter-issued actions through an in-memory process-lifetime runtime: task state is not stored in SQLite, `action_id` is single-use, task submission binds a sealed action plan plus package-version Lua object, mock download/install executors simulate task state, and `RuntimeNotification.task_changed` is pushed to Flutter as a best-effort current snapshot. CLI coverage for this model should use Rust runtime tests or a single-process scripted/debug command rather than pretending separate CLI invocations share task memory. + +## post_update + +Optional post-update hook. Most persistent state changes should remain in Rust core, not Lua. + +## Offline validation + +`getter --data-dir repo validate ` validates repository layout and package schema without network access. The command evaluates local package Lua files with the same constrained `lib/` module loading used by `repo eval`/`package eval`, then returns a getter-owned diagnostic report: + +```json +{ + "valid": false, + "network_required": false, + "package_count": 0, + "diagnostics": [ + { + "severity": "error", + "code": "package.schema", + "message": "required string field 'name' is missing", + "package_id": "android/org.fdroid.fdroid", + "location": { + "path": "repo/packages/android/org.fdroid.fdroid.lua" + } + } + ] +} +``` + +Initial stable diagnostic codes include: + +- `repository.read_repo_toml` +- `repository.parse_repo_toml` +- `repository.invalid_id` +- `repository.unsupported_api_version` +- `repository.missing_directory` +- `repository.read_packages_dir` +- `repository.invalid_package_path` +- `repository.invalid_package_id` +- `repository.hash_package_file` +- `package.read_file` +- `package.lua_runtime` +- `package.not_a_table` +- `package.unsupported_value` +- `package.schema` +- `package.domain` + +The validation command is intentionally offline. Provider/network validation belongs to later provider/update workflow commands, not repository schema validation. diff --git a/docs/lua-api/permissions.md b/docs/lua-api/permissions.md new file mode 100644 index 000000000..e30b2e786 --- /dev/null +++ b/docs/lua-api/permissions.md @@ -0,0 +1,33 @@ +# Lua Permissions + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Default + +Lua package scripts do not receive direct network access by default. + +They can use getter-provided provider/source APIs. + +## Free network permission + +A package may declare free network access for live/9999-like logic or unusual upstreams. + +When declared: + +- getter exposes a direct Lua network host API; +- Flutter displays a yellow warning tag at App detail source/version level; +- use is not blocked. + +## Timeouts + +Network operations use normal network timeouts. + +Lua script runtime itself does not use a timeout/fuel limit. + +## v1 verification policy + +v1 does not enforce repo/script/artifact verification. + +Schema fields may exist for future verification, but enforcement is not a v1 requirement. diff --git a/docs/lua-api/repository-layout.md b/docs/lua-api/repository-layout.md new file mode 100644 index 000000000..9ac55c510 --- /dev/null +++ b/docs/lua-api/repository-layout.md @@ -0,0 +1,68 @@ +# Lua Repository Layout + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +Recommended layout: + +```text +repo/ + repo.toml + packages/ + android/ + org.fdroid.fdroid.lua + magisk/ + zygisk-next.lua + lib/ + github.lua + android.lua + github_android_apk.lua + templates/ + android_installed_app.lua +``` + +## repo.toml + +```toml +id = "official" +name = "UpgradeAll Official" +priority = 0 +api_version = "getter.repo.v1" +``` + +## packages/ + +Package files are final package definitions consumed by getter. + +Path-derived package id: + +```text +packages/android/org.fdroid.fdroid.lua -> android/org.fdroid.fdroid +``` + +The file should declare the same id. getter validates consistency. + +## lib/ + +Reusable Lua modules. These are conceptually similar to eclasses but are plain Lua modules. + +```lua +local github_android = require("lib.github_android_apk") +``` + +## templates/ + +Lua generators that output package Lua file content. + +## Offline validation + +Use getter's structured validator before publishing or registering a repository: + +```bash +getter --data-dir /tmp/ua-getter repo validate /path/to/repo +``` + +The command does not require the repository to be registered and does not use the network. It checks the local layout, `repo.toml`, package path-derived ids, constrained Lua evaluation, and Rust schema/domain validation. Results are returned as JSON with `valid`, `package_count`, `network_required`, and getter-owned `diagnostics`. + +Common diagnostic codes include `repository.missing_directory`, `repository.unsupported_api_version`, `package.lua_runtime`, `package.schema`, and `package.domain`. diff --git a/docs/lua-api/templates.md b/docs/lua-api/templates.md new file mode 100644 index 000000000..d0b028c3e --- /dev/null +++ b/docs/lua-api/templates.md @@ -0,0 +1,72 @@ +# Lua Templates / Autogen + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +Templates are Lua generators that output package Lua file content. + +They are inspired by Funtoo Metatools/autogen, where autogen code produces ebuilds from upstream or structured inputs. + +## Template role + +Templates are used for: + +- generating package files from installed Android apps; +- generating package files from Magisk modules; +- repository maintainer batch generation; +- assisted package creation from GitHub/F-Droid metadata. + +Templates are not runtime package definitions. + +## Example + +```lua +return template { + id = "android_installed_app", + + generate = function(ctx, input) + return { + path = "packages/android/" .. input.package_name .. ".lua", + content = [[ +local android = require("lib.android") + +return android.local_app { + id = "android/]] .. input.package_name .. [[", + name = "]] .. input.label .. [[", + package_name = "]] .. input.package_name .. [[", +} +]] + } + end +} +``` + +## UX contract + +Generation flow: + +1. User clicks generate in Flutter. +2. Flutter calls a getter/native bridge operation for installed-autogen preview. +3. Rust calls the Android platform adapter for installed-inventory facts, then getter computes the candidate list. CLI/dev tests may still exercise this with `autogen installed preview --inventory ` fixtures. +4. Flutter shows the getter-owned preview list. +5. User confirms yes/no. +6. getter applies the accepted preview through the native bridge operation; CLI/dev tests may still use `autogen installed apply --preview --accept-all` or repeated `--accept `. +7. getter writes files under `/repositories/local_autogen`, registers the repo, records `autogen-manifest.json`, and tracks accepted packages in `main.db`. + +Cleanup flow: + +1. User clicks clear missing generated apps. +2. Flutter calls a getter/native bridge operation; Rust obtains the current installed-inventory facts through the Android platform adapter. +3. getter computes the deletion list. CLI/dev tests may still exercise this with `autogen cleanup preview --inventory ` fixtures. +4. Flutter shows the getter-owned preview list. +5. User confirms yes/no. +6. getter deletes only accepted manifest-managed `local_autogen` files/state. + +Cleanup apply refuses stale/tampered previews that do not match the current manifest, and guarded tracked-state deletion only removes rows still owned by `local_autogen` generated packages. Installed apply preserves existing user state (`enabled`, `favorite`, `pin_version`) and existing non-missing resolution metadata when a package is already tracked. If a managed autogen file has been edited, getter preserves that content into the user-authored `local` repo before regenerating or deleting the generated file. Ordinary autogen cleanup never deletes `local`. + +## Repositories + +Ordinary installed-app autogen writes to `local_autogen`, using fixed repo id `local_autogen`, default priority `-1`, and deterministic paths such as `packages/android/com.example.app.lua`. Candidates are skipped when any registered repository with priority higher than `local_autogen` already provides the same package id. + +Legacy migration may generate `local` files once as a special compatibility path. diff --git a/docs/migration/legacy-room-mapping.md b/docs/migration/legacy-room-mapping.md new file mode 100644 index 000000000..199032241 --- /dev/null +++ b/docs/migration/legacy-room-mapping.md @@ -0,0 +1,127 @@ +# Legacy Room Migration Mapping + +> Status: Draft / living design record +> Date: 2026-06-21 +> Project: UpgradeAll rewrite — Flutter APP + Rust getter core + Lua package repository model + +## Source + +Legacy Room DB: + +```text +app_metadata_database.db +version 17 +``` + +Tables: + +- `app` +- `hub` +- `extra_app` +- `extra_hub` + +## Target + +- getter main SQLite DB user state. +- `local` repository package Lua files when migration needs compatibility stubs. +- migration record table. + +## Principles + +- Migration must be automatic for normal users. +- Migration is limited and simple. +- Complex API keys/auth may be discarded. +- Per-app mapping failures should not block the entire app. + +## App mapping + +Legacy app -> new package id: + +```text +Android package -> android/ +Magisk module -> magisk/ +``` + +If bundled official repo contains a matching package, link user state to it. + +If no official match but common conversion is possible, generate local package Lua. + +If no conversion is possible, preserve installed/tracked id and mark missing package definition. + +## Hub mapping + +Legacy Hub does not map to a top-level new object. + +Its semantics are split into: + +- provider/source config; +- package source priority; +- credentials/auth settings; +- URL rewrite policy; +- migration diagnostics. + +Complex auth may be dropped. + +## ExtraApp mapping + +Map legacy mark/ignore version state into rewrite `pin_version` when possible. In the direct Room DB importer, `extra_app.mark_version_number` wins over `app.ignore_version_number` when both exist for the same package id because it is the more specific extra-app state. + +## ExtraHub mapping + +Map URL replace semantics into global download rewrite policy if safe. Otherwise drop and record warning. + +## Current CLI direct DB import + +The host-side CLI can import a copied/checkpointed Room SQLite database directly: + +```text +getter --data-dir legacy import-room-db +``` + +Current direct import scope: + +- requires `PRAGMA user_version = 17`; +- reads `app.app_id`, `app.ignore_version_number`, `app.star`; +- reads `extra_app.app_id` and `extra_app.mark_version_number`; +- maps app-id key `android_app_package` to `android/`; +- maps app-id key `android_magisk_module` to `magisk/`; +- writes getter `tracked_packages` plus the `legacy-room-v17` migration record in one transaction; +- imports valid app rows while reporting skipped-row warnings when other app rows are malformed or unsupported; +- treats a DB with app rows but zero importable app rows as `migration.invalid_db` and does not record migration completion; +- emits sanitized counts/warnings and never embeds raw DB contents, auth, or tokens in reports. + +Currently dropped with warnings: + +- `hub` rows; +- `extra_hub` rows and URL replacement policy; +- hub auth/API keys/provider credentials; +- app regex/cloud config fields whose new package equivalent is not accepted yet. + +The direct CLI reader expects Android/platform code to provide a WAL/SHM-consistent DB copy; it does not perform Android Room checkpointing itself. The first Flutter APK migration-adapter slice prepares that input with a no-UI Android MethodChannel adapter that copies the SQLite triplet (`.db`, `-wal`, `-shm`), checkpoints/canonicalizes the copy in app-private storage, and returns the copied DB path for Flutter to pass to getter. The default product migration action remains disabled until the production getter import bridge is connected. + +## Current CLI bridge bundle + +The host-side CLI implementation also accepts a deterministic JSON bridge bundle: + +```json +{ + "format": "upgradeall-legacy-room-bundle", + "version": 17, + "apps": [ + { + "kind": "android", + "installed_id": "org.fdroid.fdroid", + "official_package_available": true, + "common_conversion_available": false, + "pin_version": "1.20.0", + "favorite": true + } + ] +} +``` + +Each app maps to `tracked_packages` in getter main DB. Success reports are sanitized and include counts only; raw bundles are not copied into reports. + +## Completion + +After successful migration, write a migration record so the same migration does not rerun. diff --git a/docs/refactor/2026-06-20-refactor-plan.md b/docs/refactor/2026-06-20-refactor-plan.md new file mode 100644 index 000000000..c686678f7 --- /dev/null +++ b/docs/refactor/2026-06-20-refactor-plan.md @@ -0,0 +1,104 @@ +# 2026-06-20 Refactor Plan + +## Objective + +Prepare the UpgradeAll Flutter + getter rewrite from a clean, synced master while preserving all temporary work in stashes/backup branches. + +## Canonical source plan + +The detailed 06-20 plan has been copied into this repository at: + +- `docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md` + +Provenance: + +- Remote source: `xz@100.65.231.22:/home/xz/.hermes/plans/2026-06-20_181038-upgradeall-flutter-getter-rewrite-complete-plan.md` +- SHA-256: `a9d02ce7fb88112506580a6e5e723494016ff75cc950083f66ab93701bbc3a0a` +- The hash matches the plan that was preserved inside the pre-sync stash's untracked parent. + +## Completed preparation + +- Superproject WIP was stashed before sync. +- Getter submodule WIP was stashed before sync. +- Local pre-sync commits were preserved on backup branches. +- `master` was synced to upstream `origin/master` commit `4a1aae1d44a418989b0d3d28528cacff0cc066c0`. +- Getter submodule was synced to recorded commit `f011d9b4b9a15f83cd39c86e781ad8830a8ecae6`. +- Planning branch created: `refactor/phase0-planning-20260620`. + +## User clarification captured + +BDD Cucumber coverage is required for all user-facing functions/interfaces. The complete BDD coverage targets are the UpgradeAll App and Getter CLI. Internal interfaces should use unit tests, integration tests, and other traditional test frameworks because BDD fits integration/acceptance behavior better than algorithm-level tests. + +## Phase 0 deliverables + +- Glossary: `CONTEXT.md`. +- ADRs: `docs/adr/0001` through `0006`. +- Target architecture: `docs/architecture/target-architecture.md`. +- BDD/TDD plan: `docs/testing/bdd-plan.md`. +- Agent workflow: `docs/ai-development.md` and root `AGENTS.md`. +- Verification skeleton: `justfile`. + +## Phase 1 recommendation + +Detailed Phase 1a plan: [`phase-1-getter-cli-bdd-plan.md`](phase-1-getter-cli-bdd-plan.md). Phase 1a is the Getter CLI BDD spine inside the broader canonical Phase 1 getter workspace refactor. + +Detailed Phase 1b plan: [`phase-1b-getter-workspace-skeleton-plan.md`](phase-1b-getter-workspace-skeleton-plan.md). Phase 1b is the transitional workspace skeleton that keeps behavior in the root getter package while introducing the split-crate scaffold. The single current verification entrypoint is `just verify`, which includes Phase 1a focused behavior tests plus Phase 1b structural workspace checks. + +Do not start by implementing Flutter screens. + +Start with a testable headless slice: + +1. ADR 0007 is accepted for the Phase 1a Getter CLI command contract; future CLI changes must explicitly extend or revise that ADR. +2. Define the first Getter CLI Gherkin scenarios for initialization, app listing, hub listing, and malformed legacy bundle failure reporting. +3. Wire a minimal Cucumber runner for Getter CLI. +4. Implement the smallest CLI contract needed to make the first scenario pass. +5. Add internal Rust tests for the core behavior behind that CLI scenario. +6. Only then expose the same behavior through the app shell. + +## Decision gates before implementation + +- Choose the concrete Cucumber runner strategy for Flutter App scenarios. +- Choose the concrete command/output/error contract for the first Getter CLI slice. +- Decide whether to mine, split, or discard each part of the stashed direct-JNI/RPC rewrite. +- Confirm the first supported legacy DB schema range for migration fixtures. + +## First proposed BDD scenarios + +### Getter CLI smoke + +```gherkin +@getter-cli @smoke +Feature: Getter CLI initialization + Scenario: User initializes a new getter data directory + Given an empty getter data directory + When I run getter init for that directory + Then the command succeeds + And the getter data directory is usable +``` + +### Getter CLI migration recovery + +```gherkin +@getter-cli @migration +Feature: Legacy import failure recovery + Scenario: User receives a non-destructive report when legacy import fails + Given a corrupted legacy export bundle + When I run getter legacy import for that bundle + Then the command fails with a documented migration error + And no partially usable getter state is created + And a sanitized migration report is available +``` + +### UpgradeAll App migration recovery + +```gherkin +@app @migration +Feature: App migration recovery + Scenario: User can retry or report a failed migration + Given the app starts with a legacy database that cannot be imported + When migration fails + Then the app shows the migration recovery screen + And the user can retry migration + And the user can export a sanitized report + And starting fresh requires explicit confirmation +``` diff --git a/docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md b/docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md new file mode 100644 index 000000000..fb65d99ee --- /dev/null +++ b/docs/refactor/2026-06-20-upgradeall-flutter-getter-rewrite-complete-plan.md @@ -0,0 +1,1468 @@ +# UpgradeAll Flutter + getter Rust Core Rewrite Implementation Plan + +> **For Hermes:** Use `subagent-driven-development` skill to implement this plan task-by-task after the user explicitly asks for execution. + +**Goal:** Rewrite `DUpdateSystem/UpgradeAll` as a Flutter app shell whose durable logic lives in `DUpdateSystem/getter` as a Rust-first core, while preserving existing Android users' Room database data through a tested upgrade path. + +**Architecture:** `getter` becomes the headless product engine: storage, migrations, providers, downloads, version comparison, update orchestration, plugin registry, event streams, CLI/TUI API. `UpgradeAll` becomes a Flutter UI/platform shell with source-level customizable page modules, typed generated contracts, stable test IDs, and Android platform adapters. Android legacy migration is treated as a first-class compatibility subsystem, not a best-effort startup hack. + +**Tech Stack:** Rust workspace (`getter-core`, `getter-storage`, `getter-provider`, `getter-downloader`, `getter-plugin-api`, `getter-ffi`, `getter-rpc`, `getter-cli`), Rust-managed SQLite, Flutter/Dart, `flutter_rust_bridge` v2 or equivalent Dart FFI generator, Flutter `integration_test`, Maestro for black-box semantic UI flows, Patrol only for native OS automation, Android legacy Room migrator module for old installed users. + +--- + +## 0. Source and docs basis + +User-selected decision: + +- UI framework: **Flutter**. +- Distribution philosophy: source-level downstream customization. Users can fork, ask AI to modify pages, merge upstream, compile their own build, and rely on strong module boundaries, type checks, tests, and compile-time failures. +- Development posture: CLI/opencode/Emacs first; do not assume Android Studio. + +Read-only source inspection used: + +- `DUpdateSystem/UpgradeAll` +- `DUpdateSystem/getter` + +Relevant current-code facts: + +- `UpgradeAll/settings.gradle:13-25` defines modules: `:app`, `:core`, `:core-websdk`, `:core-utils`, `:core-shell`, `:core-downloader`, `:core-installer`, `:core-android-utils`, `:app-backup`, `:core-getter`, `:core-websdk:data`, `:core-getter:provider`, `:core-getter:rpc`. +- `UpgradeAll/app/build.gradle:71-74` still enables `dataBinding` and `viewBinding`; `app/build.gradle:131-143` already has Compose deps, but we are now choosing Flutter for the rewrite. +- `UpgradeAll/core-getter/build.gradle:37-52` already builds a Rust `api_proxy` for Android ABIs via an Android Rust Gradle plugin. +- `UpgradeAll/core-getter/src/main/java/net/xzos/upgradeall/getter/NativeLib.kt:17-26` loads `api_proxy` via `System.loadLibrary("api_proxy")` and exposes JNI `runServer`. +- `UpgradeAll/core-getter/src/main/java/net/xzos/upgradeall/getter/GetterPort.kt:25-35` starts the Rust service and creates a Kotlin `GetterService` client. +- `GetterPort.kt:147-168` already exposes `registerProvider` and `registerDownloader`. +- `UpgradeAll/core-getter/rpc/.../GetterService.kt:13-187` already defines a broad async service API for init, release lookup, cloud config, provider/downloader registration, download tasks, app manager, hub manager, extra records, Android API, notification, and cloud config manager. +- `getter/src/websdk/repo/provider.rs:22-40` has built-in Rust provider registry for GitHub, F-Droid, GitLab, and LSPosed. +- `getter/src/websdk/repo/provider.rs:48-55` supports dynamic `add_provider`. +- `getter/src/rpc/server.rs:71-85` starts JSON-RPC server; `server.rs:173-180` registers an external provider; `server.rs:187-220` handles download info and URL replacement. +- `UpgradeAll/core/src/main/java/net/xzos/upgradeall/core/database/MetaDatabase.kt:21-24` declares Room `MetaDatabase` with entities `AppEntity`, `HubEntity`, `ExtraAppEntity`, `ExtraHubEntity`, version `17`. +- `MetaDatabase.kt:55-77` registers migrations `6->7`, `7->8`, `8->9`, `9->10`, `8->10`, `10->11`, ..., `16->17`, and uses database name `app_metadata_database.db`. +- Current legacy Room v17 tables contain: + - `app`: `name`, `app_id`, `invalid_version_number_field_regex`, `include_version_number_field_regex`, `ignore_version_number`, `cloud_config`, `enable_hub_list`, `star`, `id`. + - `hub`: `uuid`, `hub_config`, `auth`, `ignore_app_id_list`, `applications_mode`, `user_ignore_app_id_list`, `sort_point`. + - `extra_app`: `id`, `app_id`, `mark_version_number`. + - `extra_hub`: `id`, `enable_global`, `url_replace_search`, `url_replace_string`. +- `UpgradeAll/core/src/main/java/net/xzos/upgradeall/core/database/migration/RustMigration.kt` already attempts Room -> Rust JSONL migration, but it currently migrates apps, hubs, and extra hubs only; it does not migrate `extra_app`, skips if `apps.jsonl` exists, and lets Rust assign random new app UUIDs. This is not enough for a safe official Flutter rewrite migration. +- `getter/src/database/mod.rs` currently uses JSONL stores: `apps.jsonl`, `hubs.jsonl`, `extra_apps.jsonl`, `extra_hubs.jsonl`. +- `getter/src/database/store.rs` rewrites whole JSONL files under file locks. This is simple, but it lacks a formal schema migration system and is not ideal as the long-term compatibility storage for old installed Android users. + +Docs checked / used as design constraints: + +- Flutter official integration testing docs: Flutter supports unit/widget/integration tests; integration tests can be run with `flutter test integration_test` on supported targets. +- Flutter official native-code binding docs: Flutter can bind to native code through Dart FFI; for Rust, a binding generator such as `flutter_rust_bridge` is the practical high-level path. +- Android Room migration docs: Room migration errors can crash users; migrations should preserve user data, rely on exported schemas, and be tested. Manual migrations are needed for complex schema changes. Exported schema JSON files should be version-controlled and used in migration tests. +- Maestro Flutter docs/search result: Maestro tests Flutter apps through the Flutter Semantics Tree; use semantic labels / `Semantics` / semantic identifiers instead of brittle localized text. +- Patrol docs: Flutter `integration_test` cannot interact with the OS itself; Patrol native automation is useful for permissions, notifications, and other native OS interactions. + +Note: the requested `grab-docs` skill is not installed in this Hermes profile. I used the closest available workflow: source-code audit + official documentation lookup. + +--- + +## 1. Non-negotiable architecture decisions + +### Decision 1: `getter` owns product logic + +`getter` owns: + +- providers and provider registry; +- download-info extraction; +- downloader task management; +- version comparison and filtering; +- update status calculation; +- app/hub/extra record storage; +- cloud config parsing/application; +- plugin manifests and plugin runtime; +- event stream; +- legacy import and new storage migrations; +- CLI/TUI command API. + +Flutter owns: + +- navigation; +- page rendering; +- platform widgets; +- source-level customizable page modules; +- Android/iOS/desktop platform adapters; +- user interaction and accessibility/semantics identifiers. + +Flutter must not own provider logic, downloader logic, version comparison, URL replacement, durable update state, or DB migration semantics. + +### Decision 2: official Android upgrade keeps package identity + +For users updating from old UpgradeAll to the Flutter rewrite: + +- Keep Android `applicationId = "net.xzos.upgradeall"` for official releases. +- Use the same signing key lineage for official upgrade builds. +- If application ID or signing key changes, the new app cannot access the old app-private Room DB path. In that case, a separate migration bridge/export release is required. + +### Decision 3: Rust storage should move from ad-hoc JSONL to Rust-managed SQLite + +Current `getter` JSONL storage is useful for early extraction but is not ideal for long-lived mobile app compatibility. + +Recommended v1 storage for Flutter rewrite: + +- Rust-managed SQLite database, e.g. `getter.db`. +- Embedded Rust migrations, versioned by `PRAGMA user_version` plus a `schema_migrations` / `migration_runs` table. +- Access through `getter-storage`, not through Dart Drift/sqflite. +- Android legacy Room DB is imported into `getter.db` exactly once. +- Existing `apps.jsonl` / `hubs.jsonl` / `extra_*.jsonl` alpha data gets its own importer. + +Rationale: + +- Old app data is already SQLite. +- SQLite has transactionality and schema migration semantics. +- Flutter/Dart storage would split ownership away from Rust core. +- JSONL whole-file rewrite becomes fragile as the data model grows. + +### Decision 4: source-level page customization, not runtime UI plugins + +The user-customization model is: + +```text +upstream source release + -> downstream user fork + -> AI modifies page modules + -> user merges upstream later + -> compiler/tests reveal breakages + -> user builds their own APK/desktop app +``` + +So the app must provide: + +- stable typed `ui_contract`; +- stable `ui_kit` components; +- upstream-owned default pages; +- downstream-owned custom page package/registry; +- strict analyzer settings; +- generated API bindings that users do not edit; +- one-command verification. + +### Decision 5: UI testability is a product requirement + +Every public page/action must have: + +- stable route ID; +- stable semantic identifier/test ID; +- loading/empty/error/content state IDs; +- widget tests where possible; +- integration tests for primary flows; +- Maestro flows for black-box AI/manual clicking; +- Patrol only where native OS automation is needed. + +--- + +## 2. Target repository layout + +Keep the two public repos conceptually separate, but make local development easy. + +### `DUpdateSystem/getter` + +```text +getter/ + Cargo.toml # workspace + crates/ + getter-core/ # pure domain: apps/hubs/releases/status/version logic + getter-storage/ # Rust SQLite, migrations, legacy imports + getter-providers/ # built-in providers + provider traits + getter-downloader/ # downloader tasks and backend routing + getter-plugin-api/ # plugin manifest, permissions, schema, ABI + getter-rpc/ # JSON-RPC/WebSocket for external plugins/automation + getter-ffi/ # Flutter-facing facade for flutter_rust_bridge + getter-cli/ # headless CLI; proves core is UI-independent + getter-tui/ # optional later; ratatui/crossterm + migrations/ + getter/ # new Rust SQLite schema migrations + legacy-room/ # docs/schema snapshots for import reference + fixtures/ + legacy-room/ # old DB fixtures for v6-v17 migration tests + providers/ # GitHub/GitLab/F-Droid/LSPosed fixtures + docs/ + adr/ + api/ + migration/ +``` + +### `DUpdateSystem/UpgradeAll` + +```text +UpgradeAll/ + AGENTS.md + justfile + pubspec.yaml # Flutter app workspace root if desired + native/ + getter/ # git submodule or pinned workspace checkout of DUpdateSystem/getter + apps/ + upgradeall_flutter/ + pubspec.yaml + lib/ + main.dart + app_shell.dart + bootstrap.dart + platform/ + routing/ + android/ # same applicationId for official upgrade + ios/ + linux/ + windows/ + macos/ + integration_test/ + test/ + packages/ + upgradeall_contract/ # generated typed Dart DTO/client facade; do not edit manually + upgradeall_ui_contract/ # PageContext, RouteSpec, UiId, PageDescriptor + upgradeall_ui_kit/ # reusable widgets/components + upgradeall_pages_default/ # upstream maintained default pages + upgradeall_pages_custom/ # downstream/user maintained page overlay; upstream touches minimally + upgradeall_pages_examples/ # examples/templates; safe for upstream edits + tools/ + gen_contract/ + verify_custom_pages/ + migrate_contract/ + ai_review/ + docs/ + adr/ + architecture/ + migration/ + ai-development.md + custom-pages.md + testing.md +``` + +Important downstream merge rule: + +- Upstream should avoid editing `packages/upgradeall_pages_custom/` after initial skeleton creation. +- Upstream examples/templates go under `packages/upgradeall_pages_examples/`. +- Users should modify `pages_custom`, not `app_shell`, not `getter`, not generated bindings. + +--- + +## 3. Flutter app architecture + +### 3.1 Runtime layers + +```text +Flutter main() + -> bootstrap platform paths + -> Android legacy migration check/import if needed + -> getter_ffi.init(data_dir, cache_dir, platform_capabilities) + -> AppShell + -> PageRegistry(default pages + custom pages) + -> PageContext(getter client, event stream, navigation, theme, platform services) +``` + +### 3.2 Dart package responsibilities + +`upgradeall_contract`: + +- Generated from `getter-ffi` / Rust DTO declarations. +- Contains `GetterClient`, DTOs, event models, error models. +- Do not manually edit. + +`upgradeall_ui_contract`: + +- Source-stable API for custom pages. +- Contains: + +```dart +abstract interface class UpgradeAllPage { + RouteSpec get route; + UiText get title; + Widget build(PageContext ctx); +} + +final class PageContext { + final GetterClient getter; + final AppNavigator nav; + final Stream events; + final PlatformServices platform; + final UpgradeAllTheme theme; +} + +final class UiId { + final String value; + const UiId(this.value); +} +``` + +`upgradeall_ui_kit`: + +- App list widget. +- Release list widget. +- Hub selector widget. +- Plugin config schema renderer. +- Download task card. +- Error panel. +- Loading/empty state components. +- Test ID / semantics helpers. + +`upgradeall_pages_default`: + +- Home page. +- App list page. +- App detail page. +- Release/download page. +- Hub manager page. +- Discover/cloud config page. +- Download task manager page. +- Settings page. +- Logs/diagnostics page. +- Migration status page. + +`upgradeall_pages_custom`: + +- User-owned replacement/additional pages. +- Custom page registry. +- Optional theme overrides. +- Must depend only on `upgradeall_ui_contract`, `upgradeall_ui_kit`, and `upgradeall_contract`. + +### 3.3 State management + +Keep state management simple and AI-readable. + +Recommended v1: + +- Use plain typed service classes + `ValueNotifier`/`StreamBuilder` where sufficient. +- If app complexity requires provider injection, use `flutter_riverpod` without codegen initially. +- Do not add heavy code generation in UI packages except generated Rust bindings. + +Rule: + +- Domain state comes from `getter` snapshots/events. +- Flutter state is view state only: selected tab, visible filter, form draft, scroll state, local animation state. + +--- + +## 4. Rust API and FFI plan + +### 4.1 Use a narrow Flutter-facing facade + +Do not expose internal Rust modules directly to Dart. + +Create `getter-ffi` facade: + +```rust +pub struct GetterHandle { /* opaque */ } + +pub async fn init(config: InitConfig) -> Result; +pub async fn list_apps(handle: &GetterHandle, query: AppQuery) -> Result; +pub async fn get_app_detail(handle: &GetterHandle, app_id: AppRecordId) -> Result; +pub async fn renew_all(handle: &GetterHandle) -> Result; +pub async fn renew_app(handle: &GetterHandle, app_id: AppRecordId) -> Result; +pub async fn list_hubs(handle: &GetterHandle) -> Result>; +pub async fn save_hub(handle: &GetterHandle, draft: HubDraft) -> Result; +pub async fn submit_download(handle: &GetterHandle, req: DownloadRequest) -> Result; +pub fn event_stream(handle: &GetterHandle) -> impl Stream; +``` + +Expose only DTOs that are stable and serializable. + +### 4.2 Keep JSON-RPC for external extensibility + +`getter-rpc` remains useful for: + +- external provider plugins; +- external downloader plugins; +- CLI/debug automation; +- eventual local daemon mode; +- integration tests independent of Flutter. + +But Flutter should normally use direct FFI bindings, not local WebSocket JSON-RPC for every UI operation. + +### 4.3 Error model + +Define typed errors in Rust and generated Dart: + +```rust +pub enum GetterError { + Storage(StorageError), + Network(NetworkError), + Provider(ProviderError), + Migration(MigrationError), + Platform(PlatformError), + Permission(PermissionError), + InvalidInput(ValidationError), +} +``` + +Each error must include: + +- stable code; +- human-readable message; +- optional recoverability flag; +- optional diagnostic ID; +- optional source record ID. + +Do not pass raw panics/strings across FFI. + +--- + +## 5. Storage design + +### 5.1 New Rust SQLite schema v1 + +Recommended core tables: + +```text +meta( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) + +schema_migrations( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL, + checksum TEXT NOT NULL +) + +migration_runs( + id TEXT PRIMARY KEY, + source_kind TEXT NOT NULL, -- legacy_room, legacy_jsonl, fresh + source_version TEXT, + source_hash TEXT, + status TEXT NOT NULL, -- started, completed, failed + started_at INTEGER NOT NULL, + completed_at INTEGER, + report_json TEXT +) + +apps( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + app_id_json TEXT NOT NULL, + app_id_hash TEXT NOT NULL, + invalid_version_number_field_regex TEXT, + include_version_number_field_regex TEXT, + ignore_version_number TEXT, + cloud_config_json TEXT, + enable_hub_list_json TEXT, + star INTEGER, + legacy_room_id INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) + +hubs( + uuid TEXT PRIMARY KEY, + hub_config_json TEXT NOT NULL, + auth_json TEXT NOT NULL, + ignore_app_id_list_json TEXT NOT NULL, + applications_mode INTEGER NOT NULL, + user_ignore_app_id_list_json TEXT NOT NULL, + sort_point INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) + +extra_apps( + id TEXT PRIMARY KEY, + app_id_json TEXT NOT NULL, + app_id_hash TEXT NOT NULL, + mark_version_number TEXT, + legacy_room_id INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) + +extra_hubs( + id TEXT PRIMARY KEY, + enable_global INTEGER NOT NULL, + url_replace_search TEXT, + url_replace_string TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) + +download_tasks(...) +provider_plugins(...) +downloader_plugins(...) +event_log(...) -- optional, bounded/rotated +``` + +For v1, JSON columns are acceptable for compatibility with current UpgradeAll model. Normalize only when there is a real query/index need. + +### 5.2 Deterministic IDs for migrated records + +Do not assign random app IDs during legacy migration. + +Use deterministic IDs: + +```text +new_app_id = UUIDv5(UPGRADEALL_LEGACY_NAMESPACE, "room-app:{legacy_room_id}:{canonical_app_id_json}") +new_extra_app_id = UUIDv5(UPGRADEALL_LEGACY_NAMESPACE, "room-extra-app:{legacy_room_id}:{canonical_app_id_json}") +``` + +Rationale: + +- migration is repeatable; +- tests are deterministic; +- logs and support reports are stable; +- migration can be retried safely. + +For hubs, preserve existing `uuid`. +For extra hubs, preserve existing text `id` (`GLOBAL` or hub UUID). + +### 5.3 Canonical JSON + +All maps/lists used as identity must be canonicalized before hashing: + +- sort object keys; +- preserve null vs missing where semantically meaningful; +- remove blank values only if legacy behavior did so; +- no whitespace; +- UTF-8. + +Write tests for canonicalization. + +--- + +## 6. Legacy Android migration strategy + +### 6.1 Supported source states + +Support these startup cases: + +1. Fresh Flutter install: no old Room DB, no getter DB. +2. Old Android UpgradeAll install with Room DB schema v6-v17. +3. Old Android UpgradeAll install with Room DB plus WAL/SHM files. +4. Intermediate alpha install with current getter JSONL store. +5. Partially completed previous migration attempt. +6. Failed migration with preserved backup. + +### 6.2 Official Android upgrade invariant + +Official upgrade can only read app-private old DB if: + +- package name/applicationId remains `net.xzos.upgradeall`; +- signing key lineage permits app update; +- Android system treats it as the same app data directory. + +If either changes, the plan must include a migration bridge release before the Flutter rewrite: + +```text +old Kotlin UpgradeAll bridge release + -> exports encrypted/signed migration bundle through SAF or app-private backup + -> Flutter rewrite imports bundle on first launch +``` + +### 6.3 Use an Android-only legacy migrator module + +Create a tiny Android library in the Flutter app, not a product logic dependency: + +```text +apps/upgradeall_flutter/android/legacy_migrator/ + src/main/kotlin/net/xzos/upgradeall/legacy_migration/ + LegacyMetaDatabase.kt + LegacyEntities.kt + LegacyConverters.kt + LegacyMigrations.kt + LegacyExportBundle.kt + LegacyMigrationRunner.kt +``` + +This module exists only to: + +- open/copy old Room DB; +- apply existing Room migrations to v17; +- export a typed migration bundle; +- never serve runtime product logic. + +Why not direct Rust import from every old schema only? + +- The existing Room migration chain already encodes legacy quirks from v6-v17. +- Room exported schema docs and `room-testing` make migration verification possible. +- Implementing every old schema conversion directly in Rust would be more error-prone. + +Long-term: after several major releases, this module can be removed only if the project formally drops direct migration from old Kotlin releases. + +### 6.4 Migration flow + +First Flutter Android launch: + +```text +1. Flutter bootstrap calls Android LegacyMigrationRunner.checkNeeded(). +2. If getter.db exists and migration_runs has completed legacy_room import, skip. +3. If old Room DB does not exist, create fresh getter.db. +4. If old Room DB exists: + a. create migration session ID; + b. copy app_metadata_database.db, -wal, -shm into private backup directory; + c. copy same files into a working DB name, e.g. legacy_migration_work.db; + d. open working DB with LegacyMetaDatabase + migrations 6->17; + e. force checkpoint on working DB; + f. export LegacyExportBundle v1; + g. close Room DB; + h. pass bundle path/hash to Rust getter-storage; + i. Rust imports bundle into getter.db inside a transaction; + j. Rust validates counts, canonical hashes, required fields; + k. mark migration_runs completed; + l. keep backup for at least N releases or until user explicitly deletes it. +``` + +Never delete old DB during the first successful migration. It can be ignored after success, but keep it for recovery. + +### 6.5 Legacy export bundle + +Use JSON for auditability initially. If size becomes an issue, add CBOR later. + +```json +{ + "format": "upgradeall.legacy.room.export.v1", + "source": { + "database_name": "app_metadata_database.db", + "room_schema_version": 17, + "identity_hash": "...", + "source_sha256": "...", + "exported_at": 1234567890, + "app_version_name": "...", + "app_version_code": 105 + }, + "apps": [ ... ], + "hubs": [ ... ], + "extra_apps": [ ... ], + "extra_hubs": [ ... ], + "warnings": [ ... ] +} +``` + +Include all four legacy tables. Current `RustMigration.kt` omits `extra_app`; the new migration must not repeat that omission. + +### 6.6 Mapping rules + +Legacy `app` -> Rust `apps`: + +- `name` -> `name` +- `app_id` JSON string -> canonical map -> `app_id_json`, `app_id_hash` +- `invalid_version_number_field_regex` -> same +- `include_version_number_field_regex` -> same +- `ignore_version_number` -> same +- `cloud_config` -> same JSON, validated against AppConfig DTO if possible +- `enable_hub_list` space-separated string -> ordered list JSON, while preserving original string if needed for compatibility +- `star` integer/null -> bool/null +- `id` long -> `legacy_room_id` +- new `id` -> deterministic UUIDv5 + +Legacy `hub` -> Rust `hubs`: + +- preserve `uuid` +- `hub_config` -> same JSON, validate against HubConfig DTO +- `auth` -> auth JSON; do not log tokens +- `ignore_app_id_list` -> canonical list JSON +- `applications_mode` -> integer/bool semantic +- `user_ignore_app_id_list` -> canonical list JSON +- `sort_point` -> integer + +Legacy `extra_app` -> Rust `extra_apps`: + +- old `id` long -> `legacy_room_id` +- `app_id` -> canonical map/hash +- `mark_version_number` -> same +- new `id` -> deterministic UUIDv5 + +Legacy `extra_hub` -> Rust `extra_hubs`: + +- preserve `id` (`GLOBAL` or hub UUID) +- `enable_global` -> bool/integer +- `url_replace_search` -> same +- `url_replace_string` -> same + +### 6.7 Migration failure behavior + +If migration fails: + +- Do not create a partially usable app state. +- Show Migration Recovery page. +- Save: + - migration session ID; + - error code; + - sanitized log; + - backup path; + - source DB hash; + - failed phase. +- Offer actions: + - retry migration; + - export migration report; + - start fresh only after explicit user confirmation; + - open issue template with sanitized details. + +No destructive fallback by default. + +### 6.8 Migration tests + +Create fixtures for at least: + +- v6 database with sample app/hub. +- v8 database after major table rewrite. +- v10 database without unique app index. +- v13 database with `extra_app` table. +- v16 database with `extra_hub` but without `include_version_number_field_regex`. +- v17 database with all fields. +- DB with WAL/SHM uncheckpointed writes. +- DB with malformed optional JSON field. +- DB with auth token; verify logs redact it. +- Existing JSONL store; import to SQLite. +- Partial migration run; retry idempotently. + +Commands: + +```bash +just test-migration +cargo test -p getter-storage legacy_room +./gradlew :legacy_migrator:testDebugUnitTest # Android side, if kept as Gradle module +flutter test test/migration_bootstrap_test.dart +``` + +--- + +## 7. Flutter UI pages and source customization + +### 7.1 Page registry + +Define page registry composition: + +```dart +final pages = [ + ...defaultPages, + ...customPages, +]; +``` + +Conflict rule: + +- Custom page with same route ID overrides default page only if explicitly declared. +- Otherwise duplicate route IDs are compile/test failures. + +### 7.2 Stable UI IDs + +Create a single source of truth: + +```dart +abstract final class UiIds { + static const homePage = UiId('home.page'); + static const homeCheckUpdates = UiId('home.check_updates'); + static const homeOpenApps = UiId('home.open_apps'); + static const appListPage = UiId('app_list.page'); + static const appListItemPrefix = 'app_list.item.'; + static const appDetailPage = UiId('app_detail.page'); + static const migrationPage = UiId('migration.page'); + static const migrationRetry = UiId('migration.retry'); +} +``` + +Every interactive widget must use semantic identifiers/labels through helper widgets: + +```dart +Widget testableButton({ + required UiId id, + required VoidCallback? onPressed, + required Widget child, +}) { + return Semantics( + identifier: id.value, // Flutter 3.19+ where available + label: id.value, // fallback for tools using labels + button: true, + child: ElevatedButton( + key: ValueKey(id.value), + onPressed: onPressed, + child: child, + ), + ); +} +``` + +Avoid localized visible text as the only selector. + +### 7.3 Custom page guardrails + +`AGENTS.md` and custom-page docs must instruct AI agents: + +```text +Allowed to edit: +- packages/upgradeall_pages_custom/** +- custom theme files +- tests under packages/upgradeall_pages_custom/test/** + +Do not edit unless explicitly requested: +- native/getter/** +- generated bindings +- platform adapters +- migration code +- app_shell bootstrap +- storage schema migrations +``` + +Never silence type errors with `dynamic`, unchecked casts, or broad `catch (_) {}`. + +--- + +## 8. AI-friendly CLI workflow + +Create one-command verification through `justfile`. + +Example: + +```make +setup: + flutter doctor + cargo --version + rustup target list --installed + +gen: + cargo run -p getter-codegen + flutter_rust_bridge_codegen generate + +format: + cargo fmt --all + dart format apps packages tools + +check: + cargo clippy --workspace --all-targets -- -D warnings + flutter analyze --fatal-infos + +test: + cargo test --workspace + flutter test + +test-migration: + cargo test -p getter-storage legacy + flutter test test/migration_bootstrap_test.dart + +test-ui: + flutter test integration_test + +build-android-debug: + flutter build apk --debug + +e2e-android: + maestro test e2e/maestro/android + +verify: gen format check test test-migration build-android-debug +``` + +AI agents should usually run: + +```bash +just verify +``` + +For page-only changes: + +```bash +just format +just check +flutter test packages/upgradeall_pages_custom +just test-ui +``` + +--- + +## 9. UI testing plan + +### 9.1 Test layers + +Layer 1: Rust core tests + +- provider fixtures; +- version comparison; +- update status; +- storage migrations; +- legacy import; +- downloader task state transitions; +- plugin permission validation. + +Layer 2: Flutter widget tests + +- page renders loading/empty/error/content states; +- page actions call typed fake `GetterClient`; +- custom page registry override works; +- semantics IDs exist. + +Layer 3: Flutter integration tests + +- app boots fresh; +- app boots after migration success; +- home -> app list -> app detail -> release list; +- renew all progress event updates UI; +- download task flow with fake backend. + +Layer 4: Maestro black-box flows + +- uses semantic IDs, not localized text; +- verifies app can be clicked by external automation; +- good for AI/manual click testing. + +Layer 5: Patrol native automation, only where needed + +- Android notification permission; +- file picker/SAF; +- install permission/system dialogs; +- notification tray interactions. + +### 9.2 Required Maestro flows + +```text +e2e/maestro/android/ + 001_fresh_launch.yaml + 002_migration_success.yaml + 003_open_app_list.yaml + 004_open_app_detail.yaml + 005_renew_all.yaml + 006_download_task.yaml + 007_migration_failure_recovery.yaml +``` + +Every flow should prefer: + +```yaml +- tapOn: + id: home.check_updates +``` + +not: + +```yaml +- tapOn: "Check updates" +``` + +### 9.3 Screenshot/visual tests + +Use screenshots for regression, not as primary selectors. + +- Golden tests for stable widgets. +- Mask dynamic data: time, progress, network text. +- Store baselines per theme/locale if needed. + +--- + +## 10. Plugin and extension plan + +### 10.1 Plugin layers + +Separate: + +1. Provider plugins: release source logic. +2. Downloader plugins: download backend logic. +3. UI configuration: declarative schemas rendered by Flutter/TUI. +4. Source-level page customizations: user-owned Flutter page modules. + +Do not conflate runtime provider plugins with source-level UI customizations. + +### 10.2 V1 plugins + +V1 should support: + +- built-in Rust providers; +- external JSON-RPC provider registration, continuing current concept; +- external JSON-RPC downloader registration; +- plugin manifest; +- config schema; +- permission declaration. + +Example manifest: + +```toml +id = "github" +kind = "provider" +version = "1.0.0" +api_version = "getter.plugin.v1" + +[permissions] +network = ["api.github.com", "github.com"] +filesystem = false + +[ui] +config_schema = "schemas/github-config.schema.json" +``` + +V2 can add Wasm/WASI sandbox plugins after the core rewrite stabilizes. + +--- + +## 11. Implementation phases + +### Phase 0: Freeze legacy baseline and document decisions + +Objective: establish known-good source points before rewriting. + +Tasks: + +1. Tag current Android/Kotlin state in `UpgradeAll`, e.g. `legacy-android-room-v17-baseline`. +2. Tag current `getter` state before storage rewrite. +3. Create ADRs: + - `docs/adr/0001-flutter-shell-rust-core.md` + - `docs/adr/0002-rust-sqlite-storage.md` + - `docs/adr/0003-source-level-page-customization.md` + - `docs/adr/0004-legacy-room-migration.md` +4. Create `docs/architecture/target-architecture.md`. +5. Create `docs/ai-development.md` and root `AGENTS.md`. + +Verification: + +```bash +git status --short +``` + +Expected: only docs/plan changes in planning stage; no code changes until execution begins. + +### Phase 1: Refactor `getter` into a Rust workspace + +Objective: isolate core logic before Flutter integration. + +Tasks: + +1. Create Cargo workspace. +2. Move storage code into `getter-storage`. +3. Move provider code into `getter-providers`. +4. Move manager/version/update logic into `getter-core`. +5. Move downloader code into `getter-downloader`. +6. Move JSON-RPC into `getter-rpc`. +7. Add `getter-cli` with minimal commands. +8. Add `getter-ffi` facade crate. + +Verification: + +```bash +cargo fmt --all --check +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +``` + +Acceptance: + +- No Android/JNI dependency in `getter-core`. +- CLI can initialize storage and list empty apps/hubs. +- Existing provider fixture tests still pass. + +### Phase 2: Replace JSONL storage with Rust SQLite + +Objective: create migration-capable storage foundation. + +Tasks: + +1. Add `getter-storage` SQLite backend. +2. Add embedded migrations. +3. Add schema metadata table. +4. Add models for apps, hubs, extra apps, extra hubs. +5. Add JSONL importer for existing alpha data. +6. Keep JSONL reader as compatibility-only module. +7. Update managers to use storage trait rather than direct JSONL store. + +Verification: + +```bash +cargo test -p getter-storage +cargo test -p getter-core +``` + +Acceptance: + +- Fresh `getter.db` creates schema v1. +- JSONL import test passes. +- Re-running import is idempotent. +- Storage transaction tests pass. + +### Phase 3: Build Android legacy Room export module + +Objective: support old installed UpgradeAll users. + +Tasks: + +1. Create Android legacy migrator module under Flutter Android host. +2. Copy/minimize legacy Room entities, converters, and migrations v6-v17. +3. Add legacy DB work-copy logic. +4. Add checkpoint logic for WAL/SHM. +5. Export `LegacyExportBundle` containing apps, hubs, extra apps, extra hubs. +6. Redact sensitive auth tokens in logs. +7. Add migration status/error DTOs for Flutter. + +Verification: + +```bash +./gradlew :legacy_migrator:testDebugUnitTest +``` + +Acceptance: + +- Can open sample v17 DB and export all four tables. +- Can open older fixture DB and migrate/export to v17 bundle. +- ExtraApp is included. +- Auth fields are present in bundle but redacted in logs. + +### Phase 4: Implement Rust legacy import + +Objective: import legacy Room export bundle into Rust SQLite. + +Tasks: + +1. Define `LegacyRoomExportBundle` Rust DTO. +2. Validate bundle format/version/hash. +3. Canonicalize app IDs and app ID lists. +4. Generate deterministic IDs. +5. Import apps/hubs/extra apps/extra hubs in one transaction. +6. Record migration run. +7. Add rollback/failed migration reporting. + +Verification: + +```bash +cargo test -p getter-storage legacy_room_import +``` + +Acceptance: + +- v17 export imports into `getter.db`. +- v6-v17 fixture exports import correctly. +- Count and field parity tests pass. +- Re-import same bundle does not duplicate records. +- Failed import leaves no partial DB state. + +### Phase 5: Create Flutter app shell + +Objective: minimal Flutter app booting against `getter`. + +Tasks: + +1. Create `apps/upgradeall_flutter`. +2. Preserve Android `applicationId = net.xzos.upgradeall`. +3. Add `native/getter` checkout/submodule. +4. Add `flutter_rust_bridge` or selected FFI generator. +5. Generate minimal Dart bindings. +6. Implement `bootstrap.dart`: + - platform paths; + - legacy migration check; + - getter init; + - error handling. +7. Implement basic AppShell and route host. + +Verification: + +```bash +flutter analyze --fatal-infos +flutter test +flutter build apk --debug +``` + +Acceptance: + +- Fresh app launches to Home page. +- `getter` initializes. +- No domain logic in Flutter shell. + +### Phase 6: Implement page contracts and default pages + +Objective: make page customization safe and typed. + +Tasks: + +1. Create `upgradeall_ui_contract`. +2. Create `upgradeall_ui_kit`. +3. Create `upgradeall_pages_default`. +4. Create `upgradeall_pages_custom` skeleton. +5. Add `UiIds` constants. +6. Add semantic/testable widget wrappers. +7. Implement default pages: + - Home. + - App list. + - App detail. + - Hub manager. + - Discover/cloud config. + - Download tasks. + - Settings. + - Migration status. + +Verification: + +```bash +flutter analyze --fatal-infos +flutter test packages/upgradeall_ui_kit +flutter test packages/upgradeall_pages_default +``` + +Acceptance: + +- Default pages compile only through `ui_contract` and `getter` client. +- Custom package can override a route. +- Widget tests verify semantic IDs. + +### Phase 7: Implement feature parity through getter API + +Objective: migrate current UpgradeAll flows to Rust-backed Flutter UI. + +Feature slices: + +1. App list and status. +2. App detail and release list. +3. Renew all / renew one. +4. Hub manager and auth editing. +5. Cloud config discover/apply. +6. Download info and download tasks. +7. URL replacement and extra hub settings. +8. Extra app mark version. +9. Settings and logs. +10. Android platform installed app scanning. +11. Android installer adapter. +12. Backup/export/import if still required. + +For each slice: + +- Write Rust core tests first. +- Add/extend FFI DTO. +- Add fake `GetterClient` for Flutter tests. +- Implement UI page. +- Add widget test. +- Add integration/Maestro flow if user-visible. + +Verification: + +```bash +just verify +just e2e-android +``` + +Acceptance: + +- Core flow works without Flutter through `getter-cli`. +- Flutter UI only renders/calls commands. + +### Phase 8: Migration end-to-end testing on Android + +Objective: prove real upgrade path. + +Tasks: + +1. Build old legacy APK with test fixture data. +2. Install old APK on emulator. +3. Seed app/hub/extra data. +4. Upgrade in-place to Flutter APK with same applicationId/signing. +5. Verify migration screen. +6. Verify data appears in Flutter UI. +7. Verify `getter.db` has imported records. +8. Verify old DB backup exists. +9. Repeat for v6/v8/v13/v16/v17 fixtures. + +Commands: + +```bash +just build-legacy-fixture-apk +just install-legacy-fixture +just seed-legacy-db-v17 +just build-android-debug +just upgrade-to-flutter-debug +just e2e-migration-android +``` + +Acceptance: + +- No data loss for apps/hubs/extra apps/extra hubs. +- WAL/SHM fixture migrates. +- Failed migration shows recovery page, not crash. +- Migration report is exportable and sanitized. + +### Phase 9: CLI/TUI proof + +Objective: prove `getter` is truly headless. + +CLI commands: + +```text +getter init +getter app list +getter app detail +getter app renew +getter renew-all +getter hub list +getter hub save +getter download submit +getter task list +getter plugin list +getter plugin register +getter legacy import-room-bundle +``` + +Verification: + +```bash +cargo run -p getter-cli -- app list +cargo run -p getter-cli -- legacy import-room-bundle fixtures/legacy-room/v17/export.json +``` + +Acceptance: + +- Main update check and migration import can run without Flutter. + +### Phase 10: Release strategy + +Objective: minimize risk for existing users. + +Stages: + +1. Internal migration test builds. +2. Public alpha with manual export/import only. +3. Beta with automatic Room migration but opt-in. +4. Release candidate with automatic migration by default. +5. Stable Flutter release. + +Release rules: + +- Same applicationId/signing for official Android upgrade. +- No destructive migration fallback. +- Keep old DB backup for at least two stable releases. +- Keep legacy migrator for enough versions to cover direct upgrades from last Kotlin release. +- Publish migration known-issues doc. + +--- + +## 12. Validation matrix + +Rust: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Flutter: + +```bash +flutter analyze --fatal-infos +flutter test +flutter test integration_test +flutter build apk --debug +``` + +Android legacy migration: + +```bash +./gradlew :legacy_migrator:testDebugUnitTest +just e2e-migration-android +``` + +Maestro: + +```bash +maestro test e2e/maestro/android +``` + +Patrol, only for native OS flows: + +```bash +patrol test -t integration_test/native_permissions_test.dart +``` + +Migration invariants: + +- Every legacy app row maps to exactly one Rust app row. +- Every legacy hub row maps to exactly one Rust hub row. +- Every legacy extra_app row maps to exactly one Rust extra_app row. +- Every legacy extra_hub row maps to exactly one Rust extra_hub row. +- Auth values are preserved in storage, redacted in logs. +- Migration is idempotent. +- Failed migration is recoverable. +- Old DB backup is kept. + +UI/testability invariants: + +- Every route has a stable route ID. +- Every primary action has a stable UI ID. +- No Maestro flow relies only on localized text. +- Custom pages compile against `ui_contract` only. +- Generated bindings are not manually edited. + +--- + +## 13. Risks and mitigations + +Risk: Flutter rewrite loses access to old app-private DB. + +- Mitigation: keep same applicationId and signing key. If not possible, ship bridge export release. + +Risk: current Room -> Rust migration misses data. + +- Mitigation: replace `RustMigration.kt` approach with explicit export bundle including all four tables; add fixture tests for `extra_app`. + +Risk: JSONL storage cannot support long-term schema evolution. + +- Mitigation: move to Rust SQLite before official Flutter release; keep JSONL importer only for alpha compatibility. + +Risk: AI/user custom pages create merge conflicts. + +- Mitigation: stable `ui_contract`, `ui_kit`, and downstream-owned `pages_custom`; upstream avoids touching custom package. + +Risk: AI UI tests become brittle. + +- Mitigation: semantic identifiers/test IDs, Maestro flows by ID, widget tests by `ValueKey`, screenshot tests only for visual regression. + +Risk: generated FFI code becomes confusing to AI. + +- Mitigation: `AGENTS.md` says never edit generated bindings; run `just gen`. + +Risk: platform-specific Android features leak into core. + +- Mitigation: define `PlatformServices` / Rust platform callback traits; keep PackageManager, installer, notifications, SAF in Flutter Android platform adapter. + +Risk: migration failure bricks startup. + +- Mitigation: migration recovery page, retry, backup, sanitized report, explicit fresh-start option only. + +--- + +## 14. Open questions to settle before execution + +1. Are official Flutter Android builds guaranteed to keep `applicationId = net.xzos.upgradeall` and signing key lineage? + - Recommended answer: yes, required for direct migration. + +2. Should `getter` use Rust SQLite immediately, or first keep current JSONL and migrate later? + - Recommended answer: Rust SQLite before official Flutter release. JSONL only as alpha compatibility import. + +3. How long should the legacy Room migrator remain in the Flutter app? + - Recommended answer: at least two stable release cycles, or until analytics/support indicates old Kotlin direct upgrades are negligible. + +4. What is the minimum old DB schema version to support? + - Recommended answer: support v6-v17 because current code has migrations from v6; below v6 requires manual bridge export or unsupported warning. + +5. Should the first Flutter release include desktop targets? + - Recommended answer: use Linux desktop as a development/test target, but Android is the official migration target first. + +6. Should user custom pages be tracked in upstream? + - Recommended answer: upstream provides skeleton and examples; after initial skeleton, upstream avoids changes in `pages_custom` except major contract migration. + +7. Should plugin runtime use Wasm in v1? + - Recommended answer: no. Use built-in Rust + external JSON-RPC first; add Wasm after core storage/migration/UI stabilizes. + +--- + +## 15. First execution batch recommendation + +Do not start by writing Flutter screens. + +Start with this order: + +1. ADRs + AGENTS.md + justfile skeleton. +2. `getter` workspace split. +3. Rust SQLite storage and migration framework. +4. Legacy Room export/import tests. +5. Minimal Flutter app shell + getter init. +6. Migration status page. +7. Home/AppList feature slice. + +Reason: if migration and headless core are wrong, Flutter page work will hide architectural mistakes. + +First concrete task after approval: + +```text +Create ADRs and an executable repo verification skeleton: +- docs/adr/0001-flutter-shell-rust-core.md +- docs/adr/0002-rust-sqlite-storage.md +- docs/adr/0003-source-level-page-customization.md +- docs/adr/0004-legacy-room-migration.md +- AGENTS.md +- justfile +``` + +Then run: + +```bash +just verify +``` + +Expected initially: verify may only check available existing pieces, but it becomes the single AI/operator entrypoint for the rest of the rewrite. diff --git a/docs/refactor/2026-06-21-reconciled-full-rewrite-plan.md b/docs/refactor/2026-06-21-reconciled-full-rewrite-plan.md new file mode 100644 index 000000000..dd60420d7 --- /dev/null +++ b/docs/refactor/2026-06-21-reconciled-full-rewrite-plan.md @@ -0,0 +1,602 @@ +# 2026-06-21 Reconciled Full Rewrite Plan + +> Status: implementation-grade plan, not implementation completion +> Scope: UpgradeAll rewrite toward **Flutter APP + Rust getter core + Lua package repository** +> Basis: `AGENTS.md`, `docs/README.md`, `docs/architecture/**`, `docs/app/flutter-ui-feature-parity-and-testing.md`, current source inspection, and context-builder/oracle findings from 2026-06-21. + +## 0. Purpose + +The user asked that the work must not stop at passing tests: the CLI and APP must actually run, and the result must be cross-platform. After clarification, the selected deliverable for this pass is **Full rewrite plan**. + +Therefore this document does **not** claim the Flutter UI, CLI, migration, or cross-platform runtime are already complete. It defines the implementation sequence and acceptance gates required before anyone may claim completion. + +## 1. Source-of-truth reconciliation + +### 1.1 Authoritative docs for future implementation + +Implementation must follow these files first: + +1. `AGENTS.md` +2. `docs/README.md` +3. `docs/architecture/upgradeall-getter-rewrite-wiki.md` +4. `docs/architecture/adr/0001-app-centric-lua-package-repository-model.md` +5. `docs/architecture/adr/0002-getter-flutter-platform-boundary.md` +6. `docs/architecture/adr/0003-legacy-room-migration.md` +7. `docs/architecture/adr/0004-sqlite-main-db-and-cache-db.md` +8. `docs/architecture/adr/0005-lua-package-api.md` +9. `docs/app/flutter-ui-feature-parity-and-testing.md` + +Older files under `docs/adr/**` and `docs/refactor/2026-06-20-*` are useful background, but where they conflict with the current architecture docs they must be revised or superseded. + +### 1.2 Conflicts to resolve before coding + +| Conflict | Current rule | Required action | +|---|---|---| +| Older docs use `hub` as a new CLI/domain concept (`getter hub list`, Hub Manager, hub tables). | Do not reintroduce the old hub-app model. Providers/sources/backends are not package identity. | Supersede old CLI ADR with package/repository/source vocabulary. Keep `hub` only as legacy migration input terminology. | +| Older plan uses a single `getter.db`. | Current ADR-0004 requires SQLite **main DB + cache DB** split. | Implement two DBs from the beginning of the new getter storage path. | +| Older plan describes source-level page customization and plugin ideas. | Runtime UI customization/plugin framework is explicitly not allowed for v1. | Only source fork/patch-stack customization is allowed. Runtime provider extensibility must be gated separately and must not become UI plugins. | +| Older plan leans toward a specific FFI generator. | Current docs require embedded Rust library / FFI-style boundary; generator is not fixed. | Choose FFI approach through an explicit gate before Flutter integration. | +| Older BDD plan sounds exhaustive. | Current testing rules say BDD is for meaningful user-visible flows; do not over-test with BDD. | Use BDD for CLI/App/migration flows; use TDD/unit/integration tests for domain algorithms. | + +## 2. Current implementation baseline + +As of this planning pass: + +- The repository is still mainly the legacy Android/Kotlin app. +- There is no Flutter project (`pubspec.yaml`/Dart entry point absent). +- A partial Rust getter workspace exists under `core-getter/src/main/rust/getter`. +- `getter-core` currently has package id, repository layout, and minimal Lua table validation tests. +- `getter-storage` currently has main/cache SQLite skeleton and pure legacy mapping helper tests. +- `getter-cli` is only a library skeleton; no runnable binary exists. +- The Android JNI/RPC path currently binds a placeholder local TCP endpoint and parks forever; it is not a full getter RPC surface. +- Existing legacy Kotlin Room → Rust migration writes toward old JSONL/RPC concepts and must not be treated as the new migration implementation. +- The git worktree is already dirty/staged from prior work, including a staged deletion of the old getter gitlink and untracked replacement workspace/docs. Before implementation work, reconcile the baseline deliberately. + +## 3. Completion definition: “actually runs” + +Passing unit tests is insufficient. A milestone may be called complete only when it provides runtime evidence. + +### 3.1 Required runtime evidence types + +1. **CLI runtime evidence** + - The `getter` CLI is invoked as an external process. + - It creates/opens real SQLite main/cache DB files under a temp data directory. + - It loads/evaluates real Lua package files from a fixture repository. + - It emits stable JSON stdout for success/failure envelopes. + - Its output is saved as test artifacts for at least smoke scenarios. + +2. **APP runtime evidence** + - Flutter app boots through the real app entry point, not just widget tests. + - At least one desktop/dev target and Android debug target are launched in smoke gates. + - UI flows use stable route/action/state IDs, not localized text-only selectors. + - App interacts with a fake/offline getter backend first, then the real FFI getter when ready. + +3. **Cross-platform evidence** + - Rust getter/core/CLI tests and smoke commands run on a host CI matrix. + - Flutter builds and smoke-runs on explicitly approved app targets. + - Path handling, data-dir handling, and fixture loading use platform-neutral temp dirs. + +4. **Migration evidence** + - Legacy Room fixture bundles import into new getter main DB inside a transaction. + - Dropped fields are documented. + - Per-app failures do not block whole-app migration. + - Global migration failure reaches a recovery UI, not a crash. + +### 3.2 Recommended first target matrix + +This matrix should be confirmed before implementation: + +| Layer | Required first target | Later expansion | +|---|---|---| +| Rust getter core/CLI | Linux host now; CI matrix Linux/macOS/Windows before release | Additional Android target builds through Gradle/NDK | +| Flutter APP | Android debug + Linux desktop dev smoke | Windows/macOS desktop smoke if they are official supported targets | +| Legacy migration | Android official upgrade path | Manual import/export recovery path for non-official builds | + +## 4. Decision gates before implementation + +Do not launch broad implementation until these decisions are recorded: + +1. **Cross-platform target scope**: Android + Linux dev smoke, or Android/Linux/Windows/macOS as release targets? +2. **Android upgrade identity**: Will official Flutter builds keep `applicationId = net.xzos.upgradeall` and signing key lineage? +3. **CLI vocabulary**: Supersede old `hub` CLI commands with `repo/source/provider/package` commands. +4. **FFI approach**: `flutter_rust_bridge`, manual C ABI, or a staged temporary JSON/RPC dev bridge. +5. **Main DB/cache DB schema**: exact v1 tables and migration mechanism. +6. **Legacy migration range**: which old Room schema versions are supported directly; which fields are dropped. +7. **Provider extensibility**: v1 built-in providers only, external JSON-RPC providers, or deferred plugin runtime. +8. **Repository layout in this repo**: keep transitional `core-getter/src/main/rust/getter` or move toward a cleaner workspace path. +9. **Baseline cleanup**: resolve staged deletion/untracked replacement workspace before code-writing subagents start. + +## 5. New CLI contract direction + +The older `getter hub list` contract must be revised. The new CLI should be package/repository-centric. + +Recommended initial grammar: + +```text +getter --data-dir init +getter --data-dir repo list +getter --data-dir repo add [--priority ] +getter --data-dir repo eval +getter --data-dir package eval [--repo ] +getter --data-dir app list +getter --data-dir app show +getter --data-dir app check [--offline-fixtures] +getter --data-dir template list [--repo ] +getter --data-dir template run --input +getter --data-dir legacy import-room-bundle +getter --data-dir storage validate +getter --data-dir diagnostics +``` + +Conventions: + +- JSON stdout is the default automation contract. +- Invalid CLI usage may use stderr/help text and exit code `2`. +- Structured command failures should emit JSON error envelopes on stdout. +- No command should require Flutter/Android APIs unless it explicitly declares a platform adapter/mock. + +Success envelope: + +```json +{ + "ok": true, + "command": "repo list", + "data": {}, + "warnings": [] +} +``` + +Error envelope: + +```json +{ + "ok": false, + "command": "legacy import-room-bundle", + "error": { + "code": "migration.invalid_bundle", + "message": "Legacy Room export bundle is invalid", + "report_path": "/path/to/report.json" + } +} +``` + +## 6. Implementation phases + +### Phase 0 — Baseline, docs reconciliation, and verification skeleton + +Goal: start from a known, reviewable baseline. + +Tasks: + +1. Resolve the current git/submodule/workspace state deliberately. +2. Add/supersede ADR for the package-centric CLI contract. +3. Mark older hub-oriented docs as legacy background or update terminology. +4. Add a root verification entrypoint (`justfile` or equivalent) that can run available checks. +5. Document the target platform matrix. + +Validation: + +```bash +git status --short +cargo test --manifest-path core-getter/src/main/rust/getter/Cargo.toml --workspace +./gradlew projects +``` + +Acceptance: + +- No hidden dirty baseline. +- New docs state that `hub` is legacy migration terminology only. +- Verification command is present even if later targets are initially skipped. + +### Phase 1 — Getter CLI executable spine + +Goal: make getter independently runnable before Flutter UI work. + +Tasks: + +1. Add a real `getter-cli` binary target. +2. Implement minimal CLI parser and JSON envelopes. +3. Implement `init`, `repo list`, `app list`, `storage validate`, and structured errors. +4. Add BDD/Gherkin CLI smoke scenarios that invoke the binary as an external process. +5. Add internal unit tests for output serialization and data-dir handling. + +Validation: + +```bash +cargo test --manifest-path core-getter/src/main/rust/getter/Cargo.toml --workspace +cargo run --manifest-path core-getter/src/main/rust/getter/Cargo.toml -p getter-cli -- --data-dir /tmp/ua-getter-smoke init +cargo run --manifest-path core-getter/src/main/rust/getter/Cargo.toml -p getter-cli -- --data-dir /tmp/ua-getter-smoke repo list +cargo run --manifest-path core-getter/src/main/rust/getter/Cargo.toml -p getter-cli -- --data-dir /tmp/ua-getter-smoke app list +cargo test --manifest-path core-getter/src/main/rust/getter/Cargo.toml -p getter-cli --test bdd_cli +``` + +Acceptance: + +- CLI creates real main/cache DB files. +- CLI returns stable JSON. +- No Android/JNI dependency appears in `getter-core` or `getter-cli`. + +### Phase 2 — Repository overlay and Lua package evaluation + +Goal: prove the app/package-centric repository model with real Lua files. + +Tasks: + +1. Implement multi-repository registry and priority resolution. +2. Implement resolved view: highest-priority package wins by package id. +3. Complete Lua evaluation boundary for JSON-like tables. +4. Add `package_from(repo, id)` with explicit repo id. +5. Add Lua override helper support through repo `lib` modules. +6. Add template listing/running skeleton. +7. Add fixture repositories: `official`, `local`, `local_autogen`. + +Validation: + +```bash +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke repo add official fixtures/repos/official --priority 0 +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke repo add local_autogen fixtures/repos/local_autogen --priority -1 +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke repo eval official +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke package eval android/org.fdroid.fdroid --repo official +cargo test -p getter-core repository lua +``` + +Acceptance: + +- `local` > `official` > `local_autogen` priority behavior is tested. +- Path-derived package id must match declared id. +- Lua runtime/schema/domain errors are distinct. +- Free-network permission is surfaced as metadata, not executed by default. + +### Phase 3 — SQLite main/cache DB foundation + +Goal: replace skeleton storage with a real package/repository/user-state schema. + +Main DB v1 should store: + +- repositories registry and priorities; +- tracked packages and enabled/favorite state; +- user source priority overrides; +- ignored versions, pins, and per-package user state; +- migration records; +- settings and credential references; +- download task persistent state. + +Cache DB v1 should store: + +- evaluated package metadata; +- Lua validation result; +- provider responses; +- release candidates; +- selected latest version; +- search/cache indexes where needed. + +Tasks: + +1. Define schema migrations for main DB and cache DB. +2. Add storage traits used by CLI/core. +3. Add cache key calculation tests including repo id/revision/package hash/API version/getter version/platform/permission mode. +4. Add fail-fast corruption/error behavior with clear diagnostics. + +Validation: + +```bash +cargo test -p getter-storage +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke storage validate +sqlite3 /tmp/ua-getter-smoke/main.db '.schema' +sqlite3 /tmp/ua-getter-smoke/cache.db '.schema' +``` + +Acceptance: + +- Main DB and cache DB are separate files. +- Storage operations are transactional. +- Cache can be cleared without losing user state. + +### Phase 4 — Update lifecycle and offline provider/download proof + +Goal: prove update behavior without relying on flaky live network. + +Tasks: + +1. Implement lifecycle validation for `preflight`, `setup`, `match`, `discover`, `prepare`, `select`, `resolve`, `post_update` where applicable. +2. Add fake/offline provider fixture responses. +3. Implement version comparison and candidate selection in Rust getter. +4. Implement update action generation (`Download`, `Install`, `OpenUrl`) with schema validation. +5. Implement download task state machine skeleton. +6. Keep direct network disabled unless package permission allows and user warning is visible. + +Validation: + +```bash +cargo test -p getter-core version repository lua lifecycle +cargo test -p getter-providers --features fixtures +cargo test -p getter-downloader +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke app check android/org.fdroid.fdroid --offline-fixtures +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke task list +``` + +Acceptance: + +- Main update flow works from CLI without Flutter. +- Offline provider fixture can produce a selected update and actions. +- Live network is not required for smoke gates. + +### Phase 5 — Getter platform boundary and FFI facade + +Goal: expose getter to hosts without leaking domain logic into Flutter. + +Tasks: + +1. Choose and document FFI approach. +2. Define narrow stable DTOs for Flutter-facing facade. +3. Define platform capability traits/callbacks for PackageManager inventory, installer, notifications, SAF/file picker, and installed version lookup. +4. Provide fake platform adapter for desktop/integration tests. +5. Keep JSON-RPC/local daemon path as optional/dev/external plugin path, not main Flutter path. + +Validation: + +```bash +cargo test -p getter-ffi +cargo test -p getter-rpc +cargo metadata --format-version 1 --manifest-path core-getter/src/main/rust/api_proxy/Cargo.toml +./gradlew projects +``` + +Acceptance: + +- Flutter/UI hosts call facade DTOs, not internal storage/provider modules. +- Android-only APIs are behind platform capabilities. +- Existing Gradle Cargo metadata path remains intact or is intentionally replaced with docs and working build files. + +### Phase 6 — Minimal Flutter app shell + +Goal: create a real cross-platform UI shell that boots. + +Tasks: + +1. Create Flutter project/workspace in the chosen repo layout. +2. Add packages for: + - app shell; + - getter contract/generated bindings; + - UI contract; + - UI kit; + - default pages; + - user/source-fork custom pages skeleton if desired, but no runtime UI plugin framework. +3. Add stable route/action/state IDs. +4. Implement bootstrap with fake getter first, then real getter init. +5. Implement minimal Home, App list, Repositories, Downloads, Logs, Settings, Migration status shell pages. + +Validation: + +```bash +flutter pub get +flutter analyze --fatal-infos +flutter test +flutter test integration_test +flutter build linux --debug +flutter build apk --debug +``` + +Runtime smoke: + +```bash +flutter run -d linux --debug +adb install -r build/app/outputs/flutter-apk/app-debug.apk +adb shell am start -W -n net.xzos.upgradeall.debug/ +adb shell pidof net.xzos.upgradeall.debug +``` + +Acceptance: + +- App boots on Linux desktop dev target and Android debug target. +- UI tests use stable IDs. +- Flutter contains no provider/update/version/storage logic. + +### Phase 7 — Flutter UI feature parity slices + +Goal: implement user-visible flows through getter APIs. + +Implement one vertical slice at a time: + +1. Home summary and update count. +2. App/package list. +3. App detail with source/version/artifact information. +4. Repository/source visibility. +5. Free-network yellow warning tag. +6. Installed autogen preview and confirmation. +7. Download task view and controls. +8. Settings. +9. Logs/diagnostics. +10. Migration/recovery page. + +For each slice: + +- Write BDD scenario for user-visible behavior. +- Add/extend getter CLI/core test if logic is new. +- Add Flutter widget/integration tests. +- Run a real UI smoke flow when the slice affects navigation or launch. + +Acceptance: + +- Every route and primary action has stable IDs. +- App pages render loading/empty/error/content states. +- BDD scenarios are meaningful and not duplicated low-level unit tests. + +### Phase 8 — Android legacy Room migration + +Goal: automatic migration for normal official Android users. + +Tasks: + +1. Confirm official app id/signing lineage. +2. Implement Android-only legacy migrator that exports Room DB v6-v17 to a sanitized bundle. +3. Include `app`, `hub`, `extra_app`, and `extra_hub` legacy tables in the export. +4. Import bundle into getter main DB transactionally. +5. Generate `local` Lua packages only for legacy migration cases where needed. +6. Preserve mapped user state; document dropped fields. +7. Implement migration success/warning/failure UI. + +Validation: + +```bash +cargo test -p getter-storage legacy_room +./gradlew :legacy_migrator:testDebugUnitTest +flutter test test/migration_bootstrap_test.dart +flutter test integration_test/migration_recovery_test.dart +``` + +End-to-end Android evidence: + +```bash +# outline; exact names depend on fixture tooling +./gradlew :app:installLegacyFixtureDebug +adb shell am start -W -n net.xzos.upgradeall/ +./gradlew :upgradeall_flutter:installDebug +adb shell am start -W -n net.xzos.upgradeall.debug/ +adb shell run-as net.xzos.upgradeall.debug ls files +``` + +Acceptance: + +- Single unmapped package does not block migration. +- Global migration failure reaches recovery UI and exportable report. +- Old DB backup is retained. +- No auth/token secret leaks in logs/reports. + +### Phase 9 — Installed autogen and local/local_autogen behavior + +Goal: implement user-visible generated fallback packages without corrupting user overrides. + +Tasks: + +1. Android adapter scans installed inventory. +2. getter computes autogen candidates. +3. UI shows confirmation list. +4. Confirm writes package files to `local_autogen`. +5. Cleanup only removes missing generated packages from `local_autogen`, never `local`. + +Validation: + +```bash +cargo test -p getter-core autogen +cargo run -p getter-cli -- --data-dir /tmp/ua-getter-smoke template run android_installed_app --input fixtures/installed/fdroid.json +flutter test integration_test/installed_autogen_test.dart +``` + +Acceptance: + +- Generated files are visible and can be evaluated by CLI. +- `local` remains untouched by ordinary cleanup. + +### Phase 10 — Cross-platform release readiness + +Goal: prove the project can be built, tested, and run on selected platforms. + +Required before release candidate: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --all-targets +cargo run -p getter-cli -- --data-dir "$TMPDIR/ua-getter" init +cargo run -p getter-cli -- --data-dir "$TMPDIR/ua-getter" repo eval official +flutter analyze --fatal-infos +flutter test +flutter test integration_test +flutter build apk --debug +flutter build linux --debug +``` + +CI matrix: + +- Linux: full Rust + Flutter Linux + Android APK build. +- macOS: Rust core/CLI + Flutter tests/build where available. +- Windows: Rust core/CLI + Flutter tests/build where available. +- Android emulator lane: install/launch smoke and critical migration/autogen/download flows. + +Acceptance: + +- Cross-platform means explicit matrix rows are green, not an informal claim. +- Any unsupported platform is named as unsupported or not-yet-release-gated. + +## 7. BDD scenario inventory + +Use Gherkin for these user-visible behaviors: + +### Getter CLI + +- Initialize a new getter data directory. +- List repositories in JSON. +- Add/evaluate a local repository. +- Evaluate a package from a fixture Lua repo. +- List tracked apps before/after adding state. +- Check one app through offline provider fixtures. +- Submit/list a download task through fake downloader. +- Reject malformed legacy import bundle without partial state. +- Report unsupported valid legacy bundle until full import is implemented. + +### Flutter APP + +- Fresh launch reaches Home. +- Home opens App list. +- App list opens App detail. +- App detail displays source/version/artifact data from fake getter. +- Free-network package displays yellow warning tag. +- Installed autogen preview asks confirmation before writing. +- Cleanup preview only targets `local_autogen`. +- Download task flow shows queued/running/succeeded/failed states. +- Migration success reaches migrated App list. +- Migration failure reaches recovery page. + +### Migration + +- Legacy v17 export imports apps and user state. +- Legacy export with extra_app preserves ignored/marked version where mapped. +- Auth/token values are preserved where supported but redacted from reports. +- Unmapped package creates warning/missing-package state, not global failure. + +## 8. Documentation updates required with implementation + +Update docs in the same patch when implementation changes any of these: + +- package/repository/Lua schema; +- CLI command grammar or JSON envelope; +- main/cache DB schema; +- migration mapping/dropped fields; +- FFI/platform capability boundary; +- UI route/action/state IDs; +- validation matrix/CI gates. + +Prefer new ADRs for costly decisions: + +- `0006-package-centric-cli-command-contract.md` under `docs/architecture/adr/`. +- `0007-ffi-binding-approach.md` if/when FFI generator is chosen. +- `0008-platform-target-matrix.md` once cross-platform targets are fixed. + +## 9. Stop rules + +Stop and ask for a decision if any implementation requires: + +- changing official Android application id or signing assumptions; +- introducing runtime UI customization/plugin framework; +- reusing old hub-app model as the new product model; +- dropping legacy migration fields not documented in migration docs; +- adding Android-specific APIs to getter core; +- putting provider/update/version/storage logic in Flutter; +- claiming cross-platform support without a runnable gate for that platform. + +## 10. First recommended implementation batch + +Do not start with Flutter screens. + +Recommended first batch: + +1. Reconcile docs and supersede `getter hub list` with package/repo CLI contract. +2. Resolve git/submodule dirty baseline. +3. Add getter CLI binary. +4. Add CLI BDD smoke for `init`, `repo list`, `app list`, and malformed legacy import failure. +5. Make CLI create real main/cache DB files and return stable JSON. +6. Add fixture repository and package evaluation CLI smoke. +7. Only then start minimal Flutter shell. + +This sequence keeps the core honest: if the CLI cannot perform the domain workflow, Flutter must not paper over the missing getter behavior. diff --git a/docs/refactor/phase-1-getter-cli-bdd-plan.md b/docs/refactor/phase-1-getter-cli-bdd-plan.md new file mode 100644 index 000000000..2d282a183 --- /dev/null +++ b/docs/refactor/phase-1-getter-cli-bdd-plan.md @@ -0,0 +1,242 @@ +# Phase 1a Plan: Getter CLI BDD Spine + +Date: 2026-06-20 + +## Purpose + +Phase 1a creates the first executable TDD spine for the rewrite without starting Flutter screen work. It is the entry spine for canonical Phase 1, not a replacement for the full getter workspace refactor. The goal is to make `getter` usable as a CLI and library-backed engine through behavior-first development. + +This phase follows the clarified testing rule: + +- User-facing interfaces require Cucumber/Gherkin BDD coverage. +- Getter CLI is a user-facing interface and needs complete BDD coverage for supported commands. +- Getter internals use traditional Rust unit/integration/property tests. + +## Strict review of the plan + +### Assumption: Start with the CLI before Flutter UI + +Verdict: keep it. + +Reason: the canonical 06-20 plan says `getter` owns product logic. A CLI-first slice exercises getter behavior without hiding engine mistakes behind UI scaffolding. + +### Assumption: Use Cucumber/Gherkin for every Rust test + +Verdict: reject it. + +Reason: the user clarified that BDD is for user-facing integration/acceptance behavior. Internal Rust behavior should keep fast traditional tests. + +### Assumption: Current `src/main.rs` means the CLI already exists + +Verdict: reject it. + +Reason: `src/main.rs` currently prints `Hello, world!`. The binary exists structurally, but the supported command contract does not exist yet. + +### Assumption: The stashed direct-JNI rewrite can be resumed as implementation + +Verdict: reject for Phase 1. + +Reason: Phase 1 is CLI/library test spine work. Stash mining is allowed only after comparing each piece against ADRs and the canonical plan. + +## Proposed test/tooling shape + +### Getter CLI BDD + +Initial runner direction: Rust Cucumber (`cucumber-rs`) for `.feature` files that invoke the `getter` binary. + +Target-aligned layout for the future getter workspace: + +```text +getter/ + crates/ + getter-cli/ + features/ + cli/ + init.feature + app_list.feature + hub_list.feature + legacy_import_room_bundle_failure.feature + tests/ + bdd_cli.rs + support/ + cli_world.rs + fixtures.rs +``` + +If implementation starts before the repository is moved to this target workspace, the temporary path under `core-getter/src/main/rust/getter/` must be treated as transitional. The test language and command contracts should still match the target layout. + +Step definitions should: + +- create an isolated temporary data directory per scenario; +- invoke the compiled `getter` binary as an external process; +- assert exit code, stdout/stderr, output schema, and filesystem/database side effects; +- avoid depending on network unless the scenario explicitly needs a mocked provider/server; +- preserve sanitized failure artifacts for debugging. + +### Internal Rust tests + +Use traditional Rust tests for: + +- command parser units; +- output schema serialization; +- storage initialization; +- canonical IDs; +- legacy import mapping; +- migration report creation; +- provider parsing; +- version comparison; +- download orchestration edge cases. + +## CLI command contract + +The executable CLI contract must be accepted before feature files are implemented. The proposed contract is recorded in [`../adr/0007-getter-cli-command-contract.md`](../adr/0007-getter-cli-command-contract.md). + +This Phase 1a plan uses that proposed grammar consistently: + +```text +getter --data-dir init +getter --data-dir app list +getter --data-dir hub list +getter --data-dir legacy import-room-bundle +``` + +Until ADR 0007 is accepted or revised, these commands are planning placeholders rather than executable supported contracts. + +## First behavior slices + +### Slice 1: CLI initializes an empty data directory + +Feature: + +```gherkin +@getter-cli @smoke +Feature: Getter CLI initialization + Scenario: User initializes a new getter data directory + Given an empty getter data directory + When I run getter init for that directory + Then the command succeeds + And the output is valid JSON + And the getter data directory is usable +``` + +Implementation work allowed by this slice: + +- Replace `Hello, world!` with minimal CLI parsing. +- Create or open canonical getter-owned SQLite storage with minimal metadata and empty app/hub tables. +- Add JSON success/error output envelope. +- Add internal tests for SQLite storage init and output serialization. + +Implementation work not allowed by this slice: + +- Full provider registry. +- Flutter UI. +- Android migration. +- Downloader implementation. + +### Slice 2: CLI lists empty app and hub catalogs + +Feature: + +```gherkin +@getter-cli @smoke +Feature: Getter CLI app listing + Scenario: User lists apps before adding any app records + Given an initialized getter data directory + When I run getter app list for that directory + Then the command succeeds + And the output contains an empty app list + +Feature: Getter CLI hub listing + Scenario: User lists hubs before adding any hub records + Given an initialized getter data directory + When I run getter hub list for that directory + Then the command succeeds + And the output contains an empty hub list +``` + +Implementation work allowed: + +- Minimal read path through getter core/library. +- Stable app-list and hub-list output DTOs. +- Internal tests for empty app and hub listing. + +### Slice 3: CLI reports non-destructive legacy import failure + +Feature: + +```gherkin +@getter-cli @migration +Feature: Legacy import failure recovery + Scenario: User receives a non-destructive report when legacy import fails + Given a corrupted legacy export bundle + And an initialized getter data directory + When I run getter legacy import-room-bundle for that bundle + Then the command fails with a documented migration error + And no partially usable imported state is created + And a sanitized migration report is available + + Scenario: User receives a not-implemented failure when a valid bundle is supplied + Given a syntactically valid but unsupported legacy export bundle + And an initialized getter data directory + When I run getter legacy import-room-bundle for that bundle + Then the command fails because import is not implemented yet +``` + +Implementation work allowed: + +- Malformed-bundle detection. +- Unsupported/Not-Implemented classification for syntactically valid bundles. +- Import error classification for `migration.invalid_bundle` and `migration.unsupported_bundle`. +- Non-destructive transaction boundary for failed import. +- Minimal sanitized JSON migration report for malformed and unsupported bundles. +- Internal tests for report redaction and no-state-change semantics. + +Implementation work not allowed: + +- Full Room export implementation. +- Full Flutter migration page. +- Real legacy schema mapping beyond malformed/corrupted bundle rejection and unsupported valid bundle handling. + +## Commit-sized sequence + +1. Add Cucumber runner dependencies and a failing `init.feature` with step skeleton. +2. Add minimal CLI parser and JSON output envelope to make `init.feature` pass. +3. Add internal Rust tests for storage init and output serialization. +4. Add failing `app_list.feature` and `hub_list.feature` for empty catalog listing. +5. Implement minimal library/core read paths to make empty app/hub listing pass. +6. Add failing `legacy_import_room_bundle_failure.feature` for malformed bundle behavior. +7. Implement migration report/error skeleton and no-state-change semantics for malformed bundles only. +8. Extend `just verify` to run getter CLI BDD and internal Rust tests. + +## Verification targets to add in Phase 1 + +Proposed future just targets: + +```make +test-getter-unit: + cargo test --manifest-path core-getter/src/main/rust/getter/Cargo.toml --lib --tests + +test-getter-bdd: + cargo test --manifest-path core-getter/src/main/rust/getter/Cargo.toml --test bdd_cli + +verify: status cargo-metadata gradle-projects test-getter-unit test-getter-bdd bdd-plan-check +``` + +## Mapping to canonical Phase 1 acceptance + +Canonical Phase 1 requires more than this CLI spine. Phase 1a contributes the first executable behavior spine, then the broader Phase 1 must still complete: + +- target getter workspace split (`getter-core`, `getter-storage`, `getter-providers`, `getter-downloader`, `getter-plugin-api`, `getter-ffi`, `getter-rpc`, `getter-cli` or accepted equivalents); +- no Android/JNI dependency inside Getter Core; +- CLI can initialize canonical storage; +- CLI can list empty apps and hubs; +- provider fixture tests for core behavior; +- `cargo test --workspace` or transitional equivalent passes. + +## Phase 1a decisions now captured + +1. ADR 0007 is accepted for the Phase 1a CLI contract; future CLI changes must explicitly extend or revise it. +2. `getter init` creates/opens SQLite immediately, not JSONL durable storage. +3. The malformed-bundle scenario is only a migration failure skeleton, not full legacy import implementation. +4. The supported legacy schema range and bundle version remain deferred until real import mapping starts. +5. Migration reports are JSON-first. Markdown support summaries can be generated later for issue templates/support. diff --git a/docs/refactor/phase-1a-work-plan.md b/docs/refactor/phase-1a-work-plan.md new file mode 100644 index 000000000..17682cb50 --- /dev/null +++ b/docs/refactor/phase-1a-work-plan.md @@ -0,0 +1,78 @@ +# Phase 1a Work Plan: Getter CLI BDD Spine + +Date: 2026-06-20 +Status: Approved to start implementation + +## Goal + +Create the first executable TDD/BDD spine for the rewrite through the Getter CLI, without starting Flutter screen work and without reviving the stashed direct-JNI/RPC rewrite as accepted architecture. + +This work implements the first user-facing CLI behavior slices from `docs/refactor/phase-1-getter-cli-bdd-plan.md` and follows the CLI contract in `docs/adr/0007-getter-cli-command-contract.md`. + +## Approved contract for this slice + +Initial supported commands: + +```text +getter --data-dir init +getter --data-dir app list +getter --data-dir hub list +getter --data-dir legacy import-room-bundle +``` + +Phase 1a constraints: + +- JSON output is the default machine-readable CLI contract. +- `--data-dir ` is mandatory in tests and early development. +- `getter init` creates/opens canonical getter-owned SQLite storage, not JSONL durable storage. +- `app list` and `hub list` return empty collections for newly initialized storage. +- `legacy import-room-bundle` in Phase 1a only covers malformed/corrupted bundle rejection plus explicit unsupported/not-implemented handling for syntactically valid bundles; no full Room import mapping yet. +- All user-facing CLI behavior added here needs Cucumber/Gherkin BDD coverage. +- Internal storage/output/parser behavior should use traditional Rust tests where appropriate. + +## Validation contract + +A successful Phase 1a implementation must provide evidence for: + +1. A Cucumber/Gherkin CLI BDD runner exists for getter CLI scenarios. +2. A failing `init` scenario was added first and is made green. +3. `getter --data-dir init` succeeds and emits valid JSON with `ok: true`. +4. `getter --data-dir app list` succeeds after init and emits an empty app list. +5. `getter --data-dir hub list` succeeds after init and emits an empty hub list. +6. `getter --data-dir legacy import-room-bundle ` fails non-destructively with a structured migration error and a sanitized JSON report path. +7. `getter --data-dir legacy import-room-bundle ` fails with a stable unsupported/not-implemented migration error and does not mutate the initialized store. +8. SQLite is used for the durable getter store initialized in this slice. +9. Traditional Rust tests cover core/internal pieces that are not best expressed as Gherkin. +10. `just verify` is extended to include the new getter CLI BDD/internal tests or a transitional target that proves them. + +## Non-goals + +- Do not implement Flutter UI. +- Do not implement full legacy Room export/import mapping. +- Do not implement provider registry, update checks, or downloads beyond what empty list scenarios require. +- Do not delete or replace Android RPC/JNI integration as part of this slice. +- Do not use JSONL as the durable product store. +- Do not apply the pre-sync stash wholesale. + +## Expected implementation order + +1. Make sure the getter submodule is on a working branch rather than detached HEAD. +2. Add Rust CLI test dependencies and the Cucumber runner skeleton. +3. Add the first Gherkin feature for `getter init` and observe it fail. +4. Implement minimal CLI parsing/output/storage init to pass `init`. +5. Add internal tests for SQLite init and output envelope serialization. +6. Add `app list` and `hub list` scenarios and implementation. +7. Add malformed legacy bundle failure scenario and minimal non-destructive report implementation. +8. Extend `just verify` with the new getter test command(s). +9. Run focused validation and report changed files, commands, failures, and residual risks. + +## Handoff requirements + +The worker must report: + +- changed files in the superproject and getter submodule; +- tests/features added; +- commands run with exit codes; +- whether SQLite storage is actually initialized; +- whether each BDD scenario passes; +- any blocked items or decisions needed before continuing. diff --git a/docs/refactor/phase-1b-getter-workspace-skeleton-plan.md b/docs/refactor/phase-1b-getter-workspace-skeleton-plan.md new file mode 100644 index 000000000..81cc2db27 --- /dev/null +++ b/docs/refactor/phase-1b-getter-workspace-skeleton-plan.md @@ -0,0 +1,48 @@ +# Phase 1b Plan: Getter Workspace Skeleton + +## Goal + +Create the Cargo workspace shape for the Getter rewrite without moving or rewriting existing behavior. Phase 1b is a transitional skeleton milestone, not completion of canonical Phase 1. + +## Scope + +- Add a Cargo workspace inside `core-getter/src/main/rust/getter`. +- Keep the existing root package named `getter` and keep its current CLI behavior in place. +- Add skeleton crates under `core-getter/src/main/rust/getter/crates/`: + - `getter-core` + - `getter-storage` + - `getter-providers` + - `getter-downloader` + - `getter-plugin-api` + - `getter-rpc` + - `getter-cli` + - `getter-ffi` +- Keep `api_proxy` compatible with `getter = { path = "../getter", features = ["rustls-platform-verifier-android"] }`. +- Resolve ADR 0007 status drift so the committed Phase 1a CLI contract is no longer treated as provisional. + +## Non-goals + +- No behavior/module moves from `core-getter/src/main/rust/getter/src/`. +- No change to supported CLI behavior. +- No claim that canonical Phase 1 is complete. +- No clippy `-D warnings` gate. +- No `cargo test --workspace` gate for this milestone. + +## Validation + +Phase 1b should validate the new workspace shape while preserving Phase 1a behavior: + +- `cargo metadata --manifest-path core-getter/src/main/rust/getter/Cargo.toml --no-deps --format-version 1` +- `cargo metadata --manifest-path core-getter/src/main/rust/api_proxy/Cargo.toml --no-deps --format-version 1` +- `cargo fmt --manifest-path core-getter/src/main/rust/getter/Cargo.toml --all --check` +- `cargo check --manifest-path core-getter/src/main/rust/getter/Cargo.toml --workspace --all-targets` +- `cargo check --manifest-path core-getter/src/main/rust/api_proxy/Cargo.toml` +- `just verify-workspace-skeleton` +- `just verify` +- `./gradlew --no-daemon projects` if not already covered by `just verify-workspace-skeleton` + +`just verify` is the single current verification entrypoint. It runs the scoped Phase 1a behavior/storage gates and the Phase 1b workspace skeleton checks without adding known-red broad getter tests, `cargo test --workspace`, or clippy `-D warnings`. + +## Notes + +This milestone creates the split-crate scaffold only. The root `getter` package remains the transitional monolith until a later approved behavior move. The `getter-core` Android/JNI guard is a structural metadata/text check for the new crate boundary; it prevents obvious dependency/reference drift but does not prove that product logic has already been isolated. diff --git a/docs/testing/bdd-plan.md b/docs/testing/bdd-plan.md new file mode 100644 index 000000000..8a010a6f8 --- /dev/null +++ b/docs/testing/bdd-plan.md @@ -0,0 +1,115 @@ +# BDD and TDD Plan + +Date: 2026-06-20 + +## Rule + +Every behavior-changing implementation starts with a failing automated test. + +Cucumber/Gherkin is required for user-facing behavior. The mandatory user-facing coverage surfaces are: + +1. UpgradeAll App workflows. +2. Getter CLI commands and contracts. +3. Migration success/failure/recovery behavior visible to users. + +Internal interfaces use traditional unit/integration/property tests unless they become supported user-facing contracts. + +## Why this split + +BDD is strongest at integration and acceptance behavior. It is not the best tool for every low-level algorithm test. Therefore: + +- Use Gherkin for observable workflows and supported command behavior. +- Use Rust/Kotlin/Dart native tests for internal logic, parsing, storage invariants, migration units, DTO serialization, and edge-case algorithms. +- Use widget/UI tests for rendering states and stable IDs. + +## Cucumber conventions + +Feature files should use product language from `CONTEXT.md`. + +Required tags: + +- `@app` for UpgradeAll App scenarios. +- `@getter-cli` for Getter CLI scenarios. +- `@migration` for legacy migration scenarios. +- `@smoke` for scenarios that must run in the fastest acceptance pass. +- `@regression` for scenarios created from bug fixes. + +Scenario naming should describe behavior, not implementation. Prefer: + +```gherkin +Scenario: User sees recoverable migration failure +``` + +not: + +```gherkin +Scenario: Rust importer returns error code 17 +``` + +## Planned suites + +### Getter CLI BDD + +Purpose: drive headless user-facing behavior before Flutter UI depends on it. + +Coverage examples: + +- Initialize a new data directory. +- Import a legacy bundle successfully. +- Report migration failure without destructive fallback. +- List apps in stable JSON output. +- Renew one app and report progress/events. +- Submit a download and report task state. +- Return documented non-zero exit codes for invalid input, network failure, and migration failure. + +Implementation direction: + +- Use Rust Cucumber for CLI behavior where practical. +- Step definitions invoke the built CLI binary and assert stdout/stderr/exit status and resulting state. +- Lower-level getter behavior stays covered by native Rust tests. + +### UpgradeAll App BDD + +Purpose: cover user-visible app behavior with stable route/action/state IDs. + +Coverage examples: + +- Fresh launch reaches the home route. +- Legacy migration success reaches the migrated app list. +- Legacy migration failure reaches recovery actions. +- User opens app list, app detail, and renew-all flow. +- User submits a download and sees task progress/failure/success state. +- Empty/loading/error/content states are addressable by stable IDs. + +Implementation direction: + +- Feature files are the acceptance source of truth. +- UI automation must use stable IDs, not localized text, wherever possible. +- The concrete runner can be implemented through Cucumber-compatible step definitions over Flutter integration tests and/or black-box automation, but the scenarios remain Gherkin. + +### Internal traditional tests + +Required for: + +- Version comparison. +- Provider parsing. +- Storage migrations and canonical IDs. +- Download orchestration edge cases. +- DTO serialization compatibility. +- Library API contract behavior that is not directly a CLI/App workflow. + +## Red-green-refactor loop + +1. Select a user-facing behavior or internal behavior. +2. Write the smallest failing test: + - Gherkin scenario for App/CLI behavior. + - Native unit/integration test for internal behavior. +3. Run the focused target and confirm failure for the expected reason. +4. Implement the smallest change. +5. Run focused tests until green. +6. Refactor with tests green. +7. Run `just verify` before handing off. + +## Phase 0 acceptance + +Phase 0 is complete when docs and verification skeleton exist. It does not need the full Cucumber runner wired yet, but it must prevent feature implementation from proceeding without a test plan and a failing-test entrypoint. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 292f52a24..19565b054 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # Build Tools agp = "9.0.1" -kotlin = "2.3.10" +kotlin = "2.3.20" ksp = "2.3.5" androidRust = "0.6.0" diff --git a/justfile b/justfile new file mode 100644 index 000000000..0dccbb6f3 --- /dev/null +++ b/justfile @@ -0,0 +1,61 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +GETTER_MANIFEST := "core-getter/src/main/rust/getter/Cargo.toml" +API_PROXY_MANIFEST := "core-getter/src/main/rust/api_proxy/Cargo.toml" +PLATFORM_ADAPTER_MANIFEST := "core-getter/src/main/rust/platform_adapter/Cargo.toml" + +verify: + just test-getter-unit + just test-getter-bdd + just test-flutter-widget + just verify-workspace-skeleton + just test-android-platform-adapter + just test-flutter-android-platform-adapter + just test-flutter-getter-cli-integration + just build-flutter-android-debug + +verify-fast: + just test-getter-unit + just test-getter-bdd + just test-flutter-widget + +test-getter-unit: + cargo test --manifest-path {{ GETTER_MANIFEST }} --workspace --lib --bins + +test-getter-bdd: + cargo test --manifest-path {{ GETTER_MANIFEST }} -p getter-cli --test bdd_cli + +test-flutter-widget: + cd app_flutter && flutter test + +test-flutter-getter-cli-integration: + cargo build --manifest-path {{ GETTER_MANIFEST }} -p getter-cli --bin getter-cli + cd app_flutter && GETTER_CLI_BIN="../core-getter/src/main/rust/getter/target/debug/getter-cli" flutter test dev_test/cli_getter_adapter_test.dart + +test-android-platform-adapter: + ./gradlew --no-daemon ':core-getter:buildDebugApi_proxyRust[arm64-v8a]' ':core-getter:buildDebugApi_proxyRust[armeabi-v7a]' ':core-getter:buildDebugApi_proxyRust[x86_64]' :core-getter:testDebugUnitTest --tests 'net.xzos.upgradeall.getter.platform.InstalledInventoryCollectorTest' :core-getter:assembleDebug + +test-flutter-android-platform-adapter: + cd app_flutter/android && ./gradlew --no-daemon :app:testDebugUnitTest --tests 'net.xzos.upgradeall.LegacyRoomImportPreparerTest' + +test-flutter-device-bridge device="emulator-5554": + cd app_flutter && flutter test integration_test/native_bridge_test.dart -d {{ device }} + +build-flutter-android-debug: + cd app_flutter && flutter build apk --debug + python3 tools/verify_flutter_apk_bridge.py app_flutter/build/app/outputs/flutter-apk/app-debug.apk + +verify-workspace-skeleton: + test "$(git ls-files -s core-getter/src/main/rust/getter | awk '{print $1}')" = "160000" + cargo metadata --manifest-path {{ GETTER_MANIFEST }} --no-deps --format-version 1 >/tmp/upgradeall-getter-metadata.json + cargo metadata --manifest-path {{ API_PROXY_MANIFEST }} --no-deps --format-version 1 >/tmp/upgradeall-api-proxy-metadata.json + cargo metadata --manifest-path {{ PLATFORM_ADAPTER_MANIFEST }} --no-deps --format-version 1 >/tmp/upgradeall-platform-adapter-metadata.json + cargo fmt --manifest-path {{ GETTER_MANIFEST }} --all --check + cargo fmt --manifest-path {{ PLATFORM_ADAPTER_MANIFEST }} --all --check + cargo check --manifest-path {{ GETTER_MANIFEST }} --workspace --all-targets + cargo check --manifest-path {{ API_PROXY_MANIFEST }} + if [ -n "${ANDROID_NDK_HOME:-}" ]; then export CC_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android23-clang"; export AR_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"; fi; cargo check --manifest-path {{ API_PROXY_MANIFEST }} --target aarch64-linux-android + cargo test --manifest-path {{ PLATFORM_ADAPTER_MANIFEST }} + cargo check --manifest-path {{ PLATFORM_ADAPTER_MANIFEST }} --target aarch64-linux-android + cd app_flutter && flutter analyze + ./gradlew --no-daemon projects diff --git a/todo.md b/todo.md new file mode 100644 index 000000000..ab642a2ec --- /dev/null +++ b/todo.md @@ -0,0 +1,567 @@ +# UpgradeAll rewrite next-step audit and plan + +Date: 2026-06-22 14:36 CST +Completion update: 2026-06-22 15:15 CST +Repo: `DUpdateSystem/UpgradeAll` +Branch checked: `rewrite/flutter-getter-spine` +Superproject HEAD checked before this document: `80e1eb60 fix(app): use Flutter-compatible AGP` +Superproject HEAD after completing the immediate CI fix: `a1c43f43 fix(app): use Flutter-compatible Kotlin plugin` +Getter submodule checked: `core-getter/src/main/rust/getter` -> `3b7613d709b405cb7229f2fbbf546c2d29ee96e6` + +This document is the canonical next-step plan after reviewing: + +- `todo-next-step.md` +- `AGENTS.md` +- `docs/README.md` +- `docs/implementation/coding-agent-handoff.md` +- `docs/architecture/upgradeall-getter-rewrite-wiki.md` +- `docs/architecture/adr/0001..0006` +- `docs/migration/legacy-room-mapping.md` +- `docs/app/flutter-ui-feature-parity-and-testing.md` +- current superproject diff/status/log +- current getter submodule diff/status/log +- current GitHub Actions state for UpgradeAll PR #514 and getter PR #54 + +## 1. Audit conclusion + +There is no major architecture drift from the original rewrite plan. + +Completion update: the immediate CI blocker described in this document has been fixed. The Kotlin Gradle Plugin was upgraded to `2.0.0`, the fix was pushed in `a1c43f43`, and both UpgradeAll PR checks are now green: + +```text +Android CI / Build: success +UpgradeAll Rewrite Validation / Rewrite validation: success +``` + +The completed work is broadly aligned with the intended direction: + +```text +Flutter shell / platform adapter only + -> no product decisions in Flutter yet +Rust getter core + -> product/domain/storage/repository/Lua/update/migration logic +Lua package repositories + -> JSON-like Lua package tables validated by Rust +SQLite main.db + cache.db + -> durable state split from rebuildable cache +``` + +The earlier caveat that the branch was not merge-ready because rewrite validation CI was red is now resolved. The remaining caveat is process discipline: the Flutter shell has been created, but it must stay a shell until the real getter bridge is designed and wired. Do not add more product UI behavior that duplicates getter logic. + +## 2. Current state evidence + +### Superproject + +```text +branch: rewrite/flutter-getter-spine +HEAD after completing the immediate CI fix: a1c43f43505924ce55095d8f342d699d4d470a2a +PR: https://github.com/DUpdateSystem/UpgradeAll/pull/514 +status after cleanup should be clean except this document update until committed +``` + +Recent superproject commits: + +```text +a1c43f43 fix(app): use Flutter-compatible Kotlin plugin +384aee6c docs: add rewrite next-step audit plan +80e1eb60 fix(app): use Flutter-compatible AGP +35e6c3d1 ci: restrict Telegram notifications to master pushes +3201d92d fix(app): use Flutter-compatible Gradle wrapper +59c1a0df fix(getter): keep Android proxy off Lua deps +a5730a98 ci: add rewrite validation workflow +4756f7c2 feat(getter): wire package-centric getter submodule +ae0d72c2 feat(app): add Flutter shell scaffold +95272873 chore: add rewrite agent guardrails +64611200 docs: add rewrite architecture records +``` + +### Getter submodule + +```text +path: core-getter/src/main/rust/getter +mode: 160000 gitlink, not vendored source +branch: rewrite/package-cli-spine +HEAD: 3b7613d709b405cb7229f2fbbf546c2d29ee96e6 +PR: https://github.com/DUpdateSystem/getter/pull/54 +``` + +Submodule integrity evidence: + +```bash +git ls-files -s core-getter/src/main/rust/getter +# expected/current: mode 160000 at 3b7613d709b405cb7229f2fbbf546c2d29ee96e6 +``` + +Getter PR checks were green at review time: + +```text +static-code-check: pass +test: pass +clippy-sarif: skipped as expected +``` + +### UpgradeAll CI state + +At review time the state was: + +```text +Android CI: success +UpgradeAll Rewrite Validation: failure +``` + +The failure in rewrite validation was: + +```text +Error: Your project's Kotlin version (1.9.22) is lower than Flutter's minimum supported version of 2.0.0. Please upgrade your Kotlin version. +``` + +Completion update: the Kotlin compatibility fix was committed and pushed, and the current PR checks are now: + +```text +Android CI / Build: success +UpgradeAll Rewrite Validation / Rewrite validation: success +``` + +Relevant files: + +```text +app_flutter/android/build.gradle +app_flutter/android/settings.gradle +app_flutter/android/app/build.gradle +``` + +Current Kotlin source after the fix: + +```groovy +// app_flutter/android/build.gradle +ext.kotlin_version = '2.0.0' +``` + +Also observed from the failed CI log: + +```text +Flutter support for Gradle 8.7.0 will soon be dropped; future minimum likely 8.14.0. +Flutter support for Android Gradle Plugin 8.6.0 will soon be dropped; future minimum likely 8.11.1. +``` + +Do not jump to AGP 9 as part of the immediate fix unless the minimal Kotlin fix proves impossible. The current failure is KGP < 2.0.0. + +## 3. Completed work vs original plan + +| Area | Plan expectation | Current implementation | Judgment | +|---|---|---|---| +| Docs / ADR first | Architecture, ADRs, AGENTS, handoff before broad coding | Present under `docs/architecture/**`, `docs/implementation/**`, `AGENTS.md` | Aligned | +| Getter as reusable core | `core-getter/src/main/rust/getter` remains a real submodule | Restored `.gitmodules`; gitlink is `160000`; getter PR exists | Aligned | +| CLI before real UI | Getter must be exercisable headlessly before product UI | CLI commands exist; BDD CLI tests exist; Flutter is still fake shell | Mostly aligned | +| Package-centric model | Avoid reviving old hub-app model | `repo/package/app/storage/legacy` CLI nouns; `hub list` documented compatibility-only | Aligned | +| SQLite storage | Use main DB + cache DB, not JSONL product store | `MainDb` and `CacheDb` implemented; `init` creates `main.db` and `cache.db` | Aligned | +| Lua package repositories | Lua files return JSON-like tables; Rust validates | `getter-core/src/lua.rs` and repository loader implemented; hardened lib search path | Aligned | +| Legacy migration | Automatic migration eventually; initial slice may be JSON bridge | JSON bridge bundle import exists; direct Room reader deferred | Partial but acceptable | +| ExtraApp preservation | Do not repeat old bug of skipping `extra_app` state | Current mapping preserves legacy version override as `pin_version` plus `favorite` from extra app slice | Aligned for current slice | +| Flutter UI | Flutter owns UI/platform only | `FakeGetterAdapter`, route keys, placeholder pages; no real product logic | Acceptable shell; freeze scope until bridge | +| Mixed TDD/BDD | TDD for Rust/domain, BDD for user-facing/integration | Rust unit tests + CLI BDD + Flutter widget tests | Aligned | +| Verification | `just verify` should be the main gate | `just verify` exists, passes locally, and passes in the rewrite validation workflow | Aligned | + +## 4. Deviations / risks to control + +### 4.1 Resolved Kotlin Gradle Plugin CI blocker + +The immediate CI blocker has been resolved. The fix was intentionally minimal: + +```text +app_flutter/android/build.gradle: ext.kotlin_version = '2.0.0' +``` + +This clears Flutter stable's Kotlin >= 2.0.0 dependency validation without changing architecture or feature scope. + +### 4.2 Flutter shell exists before real bridge + +This is acceptable only because it is still a shell: + +- fake in-memory getter adapter +- stable route/action/state keys +- no repository/update/storage decisions in Dart +- placeholders for downloads/logs/settings/migration + +Risk: if future work keeps adding screens using fake data, the project will drift into UI-first implementation and violate the original plan. + +Rule: after CI is green, the next product step must be bridge contract + real getter-backed data, not more fake UI. + +### 4.3 Getter rewrite is large and destructive by diff size + +Getter branch replaces a lot of old code: + +```text +getter diff vs master: ~4.7k insertions, ~14k deletions +``` + +This is acceptable for a rewrite branch, but PR review must explicitly call out deferred old capabilities: + +- downloader runtime +- provider implementations +- old RPC surface +- old websdk/cloud config machinery +- full migration/import/export + +Do not describe this PR as product-complete. + +### 4.4 Legacy migration is still a bridge slice, not full migration + +Current implementation accepts a deterministic JSON bridge bundle and maps `apps[]` to getter tracked package state. + +Still missing: + +- direct Android Room DB reader/exporter +- complete `hub`, `extra_app`, `extra_hub` ingestion +- WAL/SHM-safe DB copy/checkpoint path +- idempotence and partial-failure recovery +- Flutter migration UX beyond placeholder + +This is acceptable now, but must be called out in PR notes. + +### 4.5 Validation environment note + +The Kotlin fix was validated in the active agent environment with: + +```text +cd app_flutter && flutter build apk --debug +just verify +./gradlew --no-daemon ':core-getter:buildDebugApi_proxyRust[armeabi-v7a]' +``` + +CI also validated the branch with Java 21 and the current Flutter stable action. Future agents should still report the actual local toolchain used when claiming local validation, because Flutter stable's minimum Gradle/AGP/Kotlin checks can move over time. + +## 5. Completed immediate plan: make rewrite validation CI green + +Status: completed in `a1c43f43 fix(app): use Flutter-compatible Kotlin plugin`. + +What changed: + +```diff +// app_flutter/android/build.gradle +- ext.kotlin_version = '1.9.22' ++ ext.kotlin_version = '2.0.0' +``` + +Why this was enough: + +- The latest failing Rewrite Validation log reported only Flutter's Kotlin Gradle Plugin minimum-version gate. +- The existing Flutter Android template remained coherent with the minimal `buildscript` Kotlin classpath bump. +- No architecture, feature, AGP, or Gradle wrapper scope was expanded in this fix. + +Validation completed after the fix: + +```text +cd app_flutter && flutter build apk --debug +just verify +./gradlew --no-daemon ':core-getter:buildDebugApi_proxyRust[armeabi-v7a]' +``` + +GitHub Actions on PR #514 after the fix: + +```text +Android CI / Build: success +UpgradeAll Rewrite Validation / Rewrite validation: success +``` + +No committed workaround uses `--android-skip-build-dependency-validation`. + +## 6. Completed PR stabilization checklist + +### 6.1 Submodule integrity confirmed + +```text +160000 3b7613d709b405cb7229f2fbbf546c2d29ee96e6 0 core-getter/src/main/rust/getter +3b7613d709b405cb7229f2fbbf546c2d29ee96e6 core-getter/src/main/rust/getter (heads/rewrite/package-cli-spine) +``` + +The getter remains a real `160000` gitlink and is not vendored into the UpgradeAll superproject. + +### 6.2 Local scratch notes cleaned + +Temporary local scratch/review artifacts were removed after their useful content was folded into this tracked `todo.md`: + +```text +todo-next-step.md +subagent-artifacts/review-kotlin-todo.md +``` + +### 6.3 PR descriptions updated + +UpgradeAll PR #514 now states: + +- this is a rewrite spine, not a product-complete release +- docs/ADR/AGENTS were added +- getter is a submodule and points to getter PR #54 / `3b7613d709b405cb7229f2fbbf546c2d29ee96e6` +- Flutter shell is intentionally fake-adapter only +- Gradle/AGP/Kotlin compatibility fixes are included +- current CI validation is green +- deferred work includes real bridge, direct Room migration, `local_autogen`, provider/downloader/update lifecycle + +Getter PR #54 now states: + +- package-centric CLI/core rewrite +- old hub-app model is not restored +- old provider/downloader/RPC behavior is deferred, not silently retained +- Android JNI/API proxy consumers can depend on getter without pulling Lua/domain dependencies +- checks are green except optional SARIF skip + +## 7. Completed first architecture gate: Flutter-to-getter bridge contract + +Status: first implementation slice completed after the CI fix. + +What landed: + +- Added `docs/architecture/adr/0007-flutter-getter-bridge-contract.md`. +- Added Flutter bridge DTO/interface file: `app_flutter/lib/getter_adapter.dart`. +- Split fake test adapter export: `app_flutter/lib/fake_getter_adapter.dart`. +- Added `CliGetterAdapter` in `app_flutter/lib/cli_getter_adapter.dart`. +- Added a real getter-backed Flutter dev test: `app_flutter/dev_test/cli_getter_adapter_test.dart`. +- Added `just test-flutter-getter-cli-integration` and included it in `just verify`. +- Added getter CLI `legacy report-list` so Flutter/test adapters consume sanitized migration reports through the getter JSON envelope instead of reading getter's data-directory layout directly. + +Bridge direction accepted: + +- `FakeGetterAdapter` remains for deterministic widget tests. +- `CliGetterAdapter` is a development/integration bridge and test oracle against `getter-cli`; it is not the final Android production path. +- Android production should still embed getter through a native/FFI-style bridge after DTOs stabilize. +- The shared `GetterAdapter` interface now exposes the first read-only bridge surface: + - `initialize()` + - `listRepositories()` + - `listTrackedPackages()` + - `evaluatePackage(packageId, repositoryId?)` + - `readMigrationReports()` + - `loadSnapshot()` + +Validation completed: + +```text +just verify +``` + +Result: + +```text +getter unit/bin tests: pass +getter CLI BDD: 8 features, 9 scenarios, 65 steps passed +Flutter widget tests: pass +Flutter analyze: pass +Flutter getter CLI integration test: pass +Gradle project check: pass +Flutter Android debug APK build: pass +``` + +Important boundary note: + +- Flutter parses getter envelopes and renders DTOs. +- Flutter still must not implement repository resolution, Lua validation/evaluation semantics, version comparison, migration mapping, provider/source selection, cache invalidation, or download task state machines. +- If Flutter needs richer state, extend getter output first and cover it with getter tests. + +## 8. Product APK entry switch + +Decision: `app_flutter/` is the only product APK entry for the rewrite. The old native `:app` module remains in the repository as reference code only; all user-visible entry points and future flows must move to Flutter. + +Completed tasks: + +1. Added ADR-0008 to record the Flutter product APK entry decision. +2. Switched Android CI away from root `./gradlew assembleDebug/assembleRelease` product builds. +3. Android CI now runs `just verify`, builds Android Rust bridge libraries for the supported ABIs, and builds Flutter debug/release APK artifacts from `app_flutter`. +4. Release artifacts, APK info, and Telegram upload paths now use `app_flutter/build/app/outputs/flutter-apk/*.apk`. +5. Flutter release builds keep package id `net.xzos.upgradeall`; Flutter debug builds use `net.xzos.upgradeall.debug`. + +Remaining follow-up: + +1. Once the production native/FFI getter bridge is wired into `app_flutter`, add APK-level validation that the Flutter product APK contains/exercises that bridge. +2. Delete/archive legacy native UI code after Flutter feature parity is reached. + +## 9. Next product phases after bridge + +### Phase A: direct legacy Room migration + +Goal: replace bridge-only JSON import with the Android upgrade path. + +Status: getter-owned direct DB and production bridge slices are implemented. The getter CLI supports `legacy import-room-db ` for copied/checkpointed Room v17 SQLite files. It reads `app` and `extra_app`, maps known legacy app-id keys, writes `tracked_packages` plus `legacy-room-v17` in one transaction, prevents rerun, emits sanitized reports, and documents dropped hub/extra_hub fields. Android-side code exposes a no-UI MethodChannel adapter that locates, copies, and checkpoints the legacy SQLite triplet. The Flutter product APK now uses the native getter bridge for `importLegacyRoomDatabase` and `legacyReportList`; Flutter starts the adapter flow and renders getter-owned results/reports without mapping Room rows in Dart/Kotlin. + +Completed tasks: + +1. Rust direct importer opens copied legacy Room DB read-only and requires `PRAGMA user_version = 17`. +2. Rust reads durable `app` and `extra_app` fields needed for tracked package/user state. +3. Rust imports into `main.db` in one transaction. +4. Migration record prevents rerun. +5. Reports are sanitized and visible through `legacy report-list`. +6. Dropped `hub`/`extra_hub` fields are documented in `docs/migration/legacy-room-mapping.md`. + +Completed additional bridge tasks: + +7. Extract direct Room DB import/report-list behavior into reusable getter-owned `getter-operations` code shared by CLI and native bridge. +8. Wire production native bridge operations for `importLegacyRoomDatabase` and `legacyReportList` into the Flutter APK. +9. Enable the product migration page through `MethodChannelGetterAdapter` while keeping Flutter as DTO/rendering glue. + +Remaining tasks: + +1. Extend accepted mapping if future ADR accepts direct `hub`/`extra_hub` semantics. + +Acceptance progress: + +- Supported old DB fixture: done. +- Malformed/unsupported DB recovery reports: done. +- Partial prior migration/idempotence: done across direct DB and bridge bundle paths. +- Malformed optional JSON becomes warning: covered in Rust storage tests for `extra_app`. +- Mixed valid/invalid app rows import valid rows and warn: done. +- DBs with app rows but zero importable rows fail with recovery report: done. +- Report sanitization for dropped `hub`/`extra_hub` secrets and URL rewrite data: done. +- WAL/SHM pending writes: first Android adapter copy/checkpoint slice implemented; focused JVM native-adapter tests cover triplet copy, stale sidecar cleanup, missing DB behavior, and checkpointer invocation. Device integration validation on the `Pixel_9a` emulator covers Flutter MethodChannel -> Android copy/checkpoint -> JNI -> Rust getter import/report-list using a Room v17 fixture whose committed rows remain in the WAL sidecar before Android checkpointing. +- Per-app failures become warnings; global unreadable DB becomes recovery state, not crash: done for the getter-owned direct importer. + +### Phase B: `local_autogen` generation + +Goal: convert installed/legacy state into generated fallback Lua packages without mixing with user-authored overrides. + +Rules: + +```text +local = user-authored, highest priority, never overwritten silently +local_autogen = generated fallback, safe to regenerate/clean after preview +``` + +User-confirmed decisions: + +- getter creates/uses canonical `/repositories/local_autogen`. +- any registered repository with priority higher than `local_autogen` suppresses generation for a package id. +- autogen apply/cleanup are getter-managed; if a generated file has been edited, getter preserves it into `local` before regenerating/deleting. +- applying installed autogen also tracks accepted packages because user confirmation means the user wants update tracking. + +Status: getter-owned CLI/core and first production bridge slices are in progress. Implemented pure autogen planning, installed preview/apply, cleanup preview/apply, deterministic package Lua generation, manifest-managed cleanup, higher-priority coverage skips, local preservation for edited autogen files, guarded cleanup against stale/tampered previews, and preservation of existing tracked user state during autogen apply. Added Rust-active Android PackageManager inventory collection and first native bridge preview/apply operations packaged into the Flutter product APK. Added a Flutter installed-autogen preview/apply UI that renders getter-owned DTOs and calls the native bridge without Dart-led package-id or autogen decisions. + +Completed tasks: + +1. Define autogen output path and deterministic package file naming. +2. Generate Lua package stubs for installed apps not covered by higher-priority repos. +3. Add preview report before writing. +4. Add cleanup preview for missing generated apps. +5. Track accepted generated packages in getter storage without clobbering existing user state. +6. Preserve edited generated files into `local` before autogen rewrite/delete. +7. Guard cleanup deletion by current autogen manifest, repository id, and generated-package resolution. +8. Add Rust-active Android installed inventory provider/scanner path: Kotlin PackageManager facts provider, Rust JNI call/deserialization, and `api_proxy` runtime initialization. +9. Extract installed-autogen preview/apply semantics into reusable getter-owned `getter-operations` code so CLI and native bridge share the same `local_autogen` rules. +10. Add native bridge operations that combine platform scan + getter `local_autogen` preview/apply while returning getter-style JSON envelopes. +11. Wire/package a slim production bridge into `app_flutter` so the Flutter APK contains `libapi_proxy.so`, `NativeLib`, and the installed-inventory provider classes without depending on the legacy native `:app` UI or old `GetterPort` hub/RPC wrapper surface. +12. Add Flutter confirmation UX that consumes getter preview/apply DTOs and passes displayed accepted package ids back to getter/native bridge. + +Remaining tasks: + +1. Cache invalidation hooks beyond file-hash-based repository reload need to be expanded when evaluated/provider caches become active. + +Acceptance progress: + +- BDD for preview/confirm cleanup UX: done for CLI slice. +- TDD for deterministic Lua generation and no overwrite of `local`: done for core/CLI slice. +- Device integration validation on the `Pixel_9a` emulator covers Flutter MethodChannel -> JNI -> Rust platform scan -> getter autogen preview/apply for the app's own installed package. +- Yellow/free-network warning tagging remains getter-driven metadata, not hardcoded UI behavior: not needed for installed-target-only stubs in this slice. + +### Phase C: repository tooling and diagnostics + +Goal: make Lua package repositories maintainable. + +Tasks: + +1. Add `repo validate` command. +2. Add clearer package eval diagnostics with path/location. +3. Add schema docs for package lifecycle phases. +4. Add fixture repositories for success and common failure cases. +5. Add cache invalidation rules for repo changes. + +Acceptance: + +- `getter --data-dir repo validate ` returns structured JSON. +- Invalid Lua/schema/domain errors point to file and field. +- No network is required for repository validation unless explicitly requested. + +### Phase D: update/download/install lifecycle + +Goal: move from static app/repo display to real update workflows. + +Status: ADR-0011 is accepted for the first in-memory Lua runtime/task/action/RuntimeNotification architecture. Earlier offline CLI/dev scaffolding proved update-check and fake task DTOs, but ADR-0011 supersedes the persisted fake task model: task state is process-memory only, `action_id` is single-use, task submission binds a sealed action plan plus in-memory package-version Lua object, and Flutter receives best-effort push `RuntimeNotification.task_changed` snapshots. The first runtime core plus shared JSON operation seam, offline update-check action issuance, registered-package/static-update action issuance, and native bridge/EventChannel skeleton are implemented. Live provider action issuance, real downloads, Android installers, background workers, and Android system notifications remain deferred to later ADRs. + +Completed tasks: + +1. Add offline update-check fixture DTO and result/status DTO in getter core. +2. Reuse existing getter-core update selection for update availability. +3. Generate minimal download/install action DTOs for the selected artifact. +4. Add CLI command `update check --fixture `. +5. Add BDD coverage for update available, up to date, `pin_version` baseline override, unknown installed version, and malformed fixture. +6. Add getter-core task/event/install-handoff DTOs for the first offline lifecycle proof. +7. Add main DB task/event/install-handoff tables and storage APIs with TDD coverage. +8. Implement deterministic fake/offline downloader behavior beyond the previous placeholder crate: submit, run, cancel, list, poll events, and record install result. +9. Add debug fake-task CLI commands and BDD coverage for persisted offline scaffold operations: submit, run, list, cancel, events, and install-result. +10. Accept ADR-0011 for the in-memory runtime/task/action/RuntimeNotification model. +11. Add `getter-core::runtime` with in-memory `GetterRuntime`, single-use `action_id`, sealed action plans, package-version Lua object binding, generic `user-result`, mock download/install state, package-level non-waiting lock, task controls, remove/clean, and RuntimeNotification DTOs with TDD coverage. +12. Add shared `getter-operations::runtime` JSON controls for offline update-check action issuance plus submit/get/list/start/progress/complete-download/pause/resume/user-result/cancel/retry/remove/clean without persisted task state. + +Completed additional UI/bridge slice: + +10. Extend Flutter getter bridge DTOs/adapters with read-only debug fake-task list and event page APIs backed by getter CLI `debug fake-task list/events`. +11. Render getter-owned task/event DTOs on the initial Flutter Downloads route without adding a Dart task state machine. +12. Add Flutter widget/dev integration coverage for reading and rendering getter debug fake-task lifecycle DTOs. +13. Add native bridge process-lifetime runtime singleton, runtime operation dispatcher, bounded best-effort notification drain, Kotlin EventChannel, and Dart runtime notification stream primitive. +14. Add typed Dart runtime/update methods for package update-check action issuance, action-id submission, task query/control/user-result/remove/clean, typed runtime notifications, and runtime task snapshot rendering on the Downloads route. +15. Add `runtime script --script ` as a single-process CLI debug harness over `getter-operations::runtime`, including task remove/clean coverage, and move the old persisted fake downloader scaffold out of the public `task` namespace to `debug fake-task ...`. +16. Remove remaining `debug fake-task` DTO accessors from `CliGetterAdapter` and keep fake-task coverage inside getter CLI BDD/dev scaffolding only, so Flutter product/development adapters no longer expose the old persisted task/event DTO surface. + +Remaining tasks: + +1. Replace the current static `updates` package seam with live provider update-check action issuance that materializes sealed action plans and returns opaque `action_id` to Flutter; keep Dart from constructing action payloads. +2. Implement live provider/downloader behavior beyond the fake/offline proof after a later ADR accepts real side-effect details. +3. Define Android production install handoff URI/SAF/permission/notification details and wire platform adapter execution after later ADRs. +4. Add product-level Flutter BDD for full update/download/install user flows after live/provider/background/installer decisions are accepted; the current slice covers typed runtime DTO methods and read-only runtime task snapshot rendering. + +Acceptance progress: + +- CLI can run an offline fixture update check: done. +- Older CLI/dev fake task scaffold can persist/list task state: done under `debug fake-task ...`; it is development-only and superseded by ADR-0011 for product runtime tasks. +- `getter-core::runtime` can manage in-memory tasks, controls, `user-result`, retry, package lock, remove/clean, and notifications: first TDD slice done. +- Getter can expose pollable task events with cursor/limit only in the `debug fake-task` scaffold; ADR-0011 native push stream skeleton is done with bounded best-effort EventChannel delivery and typed current-state query operations in Flutter. +- Getter can record abstract install handoff requests/results in the debug fake-task scaffold; ADR-0011 uses generic `user-result` and mock install waiting-user state, Android installer execution remains deferred. +- Flutter displays getter runtime task snapshots rather than calculating status itself: done for read-only typed runtime snapshot rendering. +- Android platform adapter owns permissions/notifications/installer handoff: documented/deferred; no Android execution added in this slice. + +## 10. Do-not-do list for the next agent + +- Do not add more fake product screens before fixing CI and defining the bridge. +- Do not move provider/update/storage/migration logic into Flutter. +- Do not vendor getter source into the UpgradeAll superproject. +- Do not revive old hub-app architecture; `hub list` is compatibility-only. +- Do not use random UUIDs as primary package identity. +- Do not claim migration complete while direct Room DB ingestion is missing. +- Do not bypass Flutter dependency validation as a committed workaround. +- Do not commit generated build outputs: + - `target/` + - `build/` + - `.dart_tool/` + - `.gradle/` + - APK/AAB/SO/class/object outputs + - `local.properties` + - `.pi/` + - `context-build/` + +## 11. Quick commands for the next session + +```bash +cd ~/Code/DUpdateSystem/UpgradeAll + +git status --short --branch --untracked-files=all +git submodule status --recursive + +gh run list --repo DUpdateSystem/UpgradeAll --branch rewrite/flutter-getter-spine --limit 10 +gh pr checks 514 --repo DUpdateSystem/UpgradeAll +gh pr checks 54 --repo DUpdateSystem/getter + +# after Kotlin fix +cd app_flutter && flutter build apk --debug +cd .. +just verify + +# if CI needs manual dispatch +gh workflow run upgradeall-rewrite-validation.yml --repo DUpdateSystem/UpgradeAll --ref rewrite/flutter-getter-spine +gh workflow run android.yml --repo DUpdateSystem/UpgradeAll --ref rewrite/flutter-getter-spine +``` diff --git a/tools/verify_flutter_apk_bridge.py b/tools/verify_flutter_apk_bridge.py new file mode 100644 index 000000000..81e12bb31 --- /dev/null +++ b/tools/verify_flutter_apk_bridge.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Verify that a Flutter APK packages the getter native bridge.""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + +EXPECTED_ABIS = ("arm64-v8a", "armeabi-v7a", "x86_64") +DEX_MARKERS = ( + b"net/xzos/upgradeall/getter/NativeLib", + b"net/xzos/upgradeall/getter/platform/InstalledInventoryProvider", +) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: verify_flutter_apk_bridge.py ", file=sys.stderr) + return 2 + + apk = Path(sys.argv[1]) + missing: list[str] = [] + with zipfile.ZipFile(apk) as archive: + names = set(archive.namelist()) + missing.extend( + f"lib/{abi}/libapi_proxy.so" + for abi in EXPECTED_ABIS + if f"lib/{abi}/libapi_proxy.so" not in names + ) + dex = b"".join( + archive.read(name) + for name in names + if name.startswith("classes") and name.endswith(".dex") + ) + + missing.extend(marker.decode() for marker in DEX_MARKERS if marker not in dex) + if missing: + print(f"missing from {apk}: {', '.join(missing)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())