From 6587d30df4d6f88e9899d33adae49e3d43a58f02 Mon Sep 17 00:00:00 2001 From: chenyulong <306008324@qq.com> Date: Thu, 4 Jun 2026 09:29:05 +0800 Subject: [PATCH 1/2] Document Android library v0.1.0 design --- .../2026-06-04-android-library-v0.1.0.md | 93 +++++++++++++++++++ ...026-06-04-android-library-v0.1.0-design.md | 71 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-android-library-v0.1.0.md create mode 100644 docs/superpowers/specs/2026-06-04-android-library-v0.1.0-design.md diff --git a/docs/superpowers/plans/2026-06-04-android-library-v0.1.0.md b/docs/superpowers/plans/2026-06-04-android-library-v0.1.0.md new file mode 100644 index 0000000..74adf24 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-android-library-v0.1.0.md @@ -0,0 +1,93 @@ +# LegitKeepAlive Android Library v0.1.0 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a reusable `keepalive-core` Android library, retain `app` as a +sample consumer, and publish release-ready `v0.1.0` artifacts. + +**Architecture:** Move OEM rules and non-UI behavior into a library with one +public facade. Keep the sample dependent only on the public API. Publish the +release variant through Maven Publish and attach built artifacts to GitHub. + +**Tech Stack:** Kotlin, Android SDK, Kotlin Serialization, Gradle Kotlin DSL, +Maven Publish, JUnit 4, GitHub Releases. + +--- + +### Task 1: Add the library module and rule-resolution tests + +**Files:** +- Create: `keepalive-core/build.gradle.kts` +- Create: `keepalive-core/src/main/AndroidManifest.xml` +- Create: `keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/RuleResolverTest.kt` +- Modify: `settings.gradle.kts` +- Modify: `build.gradle.kts` +- Modify: `gradle/libs.versions.toml` + +- [ ] Add the Android Library, Kotlin serialization, and Maven Publish plugin + configuration. +- [ ] Write tests that require version matching, fallback behavior, duplicate + ID validation, and valid navigation instructions. +- [ ] Run `./gradlew :keepalive-core:testDebugUnitTest` and confirm the tests + fail because the internal resolver and models do not exist. + +### Task 2: Implement the reusable core and public API + +**Files:** +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/LegitKeepAlive.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/KeepAliveRecommendation.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/OpenSettingsResult.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/ConfigModels.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/RuleResolver.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsRepository.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/StatusChecker.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactory.kt` +- Create: `keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SystemPropertyReader.kt` +- Move: `app/src/main/assets/*.json` to `keepalive-core/src/main/assets/legitkeepalive/` + +- [ ] Implement the minimum internal models and resolver required by the tests. +- [ ] Run `./gradlew :keepalive-core:testDebugUnitTest` and confirm the resolver + tests pass. +- [ ] Implement the Android-facing facade, status checks, repository, and + settings navigation. + +### Task 3: Convert the app into a public-API sample + +**Files:** +- Modify: `app/build.gradle.kts` +- Modify: `app/src/main/java/com/ght/keepalive/MainActivity.kt` +- Modify: `app/src/main/java/com/ght/keepalive/adapt/KeepAliveAdapter.kt` +- Modify: `app/src/main/AndroidManifest.xml` +- Delete: app-only copies of core models, repository, status helpers, and + permission sample code. + +- [ ] Make `app` depend on `project(":keepalive-core")`. +- [ ] Replace all internal implementation usage with `LegitKeepAlive` public + API calls. +- [ ] Remove dependencies and files no longer needed by the sample. +- [ ] Run `./gradlew :app:assembleDebug` and confirm the sample builds. + +### Task 4: Add release documentation and automation + +**Files:** +- Modify: `README.md` +- Create: `CHANGELOG.md` +- Create: `CONTRIBUTING.md` +- Create: `.github/workflows/android.yml` +- Create: `docs/releases/v0.1.0.md` + +- [ ] Document Gradle/JitPack integration and public API usage. +- [ ] Document supported OEM coverage and known limitations. +- [ ] Add CI for tests, lint, and release builds. +- [ ] Add `v0.1.0` release notes and changelog. + +### Task 5: Verify and publish + +- [ ] Run: + `./gradlew clean test lint assembleRelease publishReleasePublicationToMavenLocal` +- [ ] Confirm the AAR, sources JAR, sample APK, and Maven Local publication + exist. +- [ ] Generate SHA-256 checksum files for the AAR and sample APK. +- [ ] Commit and push the release changes. +- [ ] Create tag and GitHub Release `v0.1.0` with release notes and artifacts. + diff --git a/docs/superpowers/specs/2026-06-04-android-library-v0.1.0-design.md b/docs/superpowers/specs/2026-06-04-android-library-v0.1.0-design.md new file mode 100644 index 0000000..cbcc13d --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-android-library-v0.1.0-design.md @@ -0,0 +1,71 @@ +# LegitKeepAlive Android Library v0.1.0 Design + +## Goal + +Turn LegitKeepAlive from a standalone Android demo into a reusable Android +library with a small sample application, then publish the first formal +`v0.1.0` release. + +## Architecture + +The repository will contain two Android modules: + +- `keepalive-core`: the reusable Android library. It owns OEM configuration + assets, configuration parsing, ROM rule selection, status checks, settings + intent creation, and the public API. +- `app`: a sample application. It owns only presentation code and demonstrates + how an external application integrates the library. + +The public entry point is `LegitKeepAlive.create(context)`. Consumers use it to +retrieve recommendations, check a recommendation's current state when +supported, create a settings intent, or open the settings page. + +## Public API + +```kotlin +val keepAlive = LegitKeepAlive.create(context) +val recommendations = keepAlive.recommendations() +val enabled: Boolean? = keepAlive.isEnabled(recommendation) +val intent: Intent? = keepAlive.createSettingsIntent(recommendation) +val result: OpenSettingsResult = keepAlive.openSettings(context, recommendation) +``` + +`Boolean?` distinguishes an enabled/disabled setting from a setting that +cannot be checked programmatically. `OpenSettingsResult` distinguishes opened, +unsupported, and failed navigation attempts without throwing into consumer +code. + +## Configuration + +OEM JSON files move into the library under +`keepalive-core/src/main/assets/legitkeepalive/` to avoid asset-name collisions +in consuming applications. The library falls back to `default.json` when no +OEM-specific file exists. + +Configuration IDs remain strings in the public model so adding new OEM rules +does not require a binary-breaking enum change. + +## Publishing + +The library uses `maven-publish` with: + +- Group: `io.github.ityulong` +- Artifact: `legitkeepalive` +- Version: `0.1.0` + +The release build produces a sources JAR and AAR. GitHub release `v0.1.0` +includes the release AAR, sample APK, SHA-256 checksums, integration +instructions, supported OEM coverage, and known limitations. + +## Testing + +Local unit tests cover: + +- default rules when no version-specific match exists +- version-specific rules when a ROM version matches +- duplicate setting ID rejection +- invalid or empty settings intent rejection + +The final verification command builds the library and sample, runs unit tests +and Android lint, and publishes the release artifact to Maven Local. + From f430ad367b1919d78a23762e9c21a37ce08fda7e Mon Sep 17 00:00:00 2001 From: chenyulong <306008324@qq.com> Date: Thu, 4 Jun 2026 09:50:15 +0800 Subject: [PATCH 2/2] Release LegitKeepAlive Android library v0.1.0 --- .github/workflows/android.yml | 40 +++ .github/workflows/release.yml | 43 +++ .gitignore | 3 +- CHANGELOG.md | 22 ++ CONTRIBUTING.md | 47 +++ README.md | 218 +++++-------- SECURITY.md | 13 + app/build.gradle.kts | 32 +- .../ght/keepalive/ExampleInstrumentedTest.kt | 24 -- app/src/main/AndroidManifest.xml | 3 +- .../java/com/ght/keepalive/MainActivity.kt | 106 ++----- .../java/com/ght/keepalive/MainViewModel.kt | 68 ----- .../java/com/ght/keepalive/MyApplication.kt | 17 -- .../ght/keepalive/adapt/KeepAliveAdapter.kt | 20 +- .../com/ght/keepalive/enumid/SettingId.kt | 44 --- .../ght/keepalive/model/KeepAliveConfig.kt | 45 --- .../permission/PermissionConverter.java | 288 ------------------ .../permission/PermissionDescription.java | 238 --------------- .../permission/PermissionInterceptor.java | 186 ----------- .../permission/WindowLifecycleManager.java | 122 -------- .../ght/keepalive/util/OemPermissionHelper.kt | 162 ---------- .../keepalive/util/StatusCheckerRegistry.kt | 70 ----- .../com/ght/keepalive/util/SystemUtils.kt | 29 -- .../java/com/ght/keepalive/util/ToastUtil.kt | 23 -- .../com/ght/repository/SettingsRepository.kt | 33 -- .../permission_description_popup_bg.xml | 13 - .../layout/permission_description_popup.xml | 43 --- app/src/main/res/values/strings.xml | 74 +---- .../java/com/ght/keepalive/ExampleUnitTest.kt | 17 -- build.gradle.kts | 4 +- docs/oem-support-matrix.md | 2 +- docs/releases/v0.1.0.md | 26 ++ gradle.properties | 2 + gradle/libs.versions.toml | 41 +-- jitpack.yml | 6 + keepalive-core/build.gradle.kts | 85 ++++++ keepalive-core/consumer-rules.pro | 1 + keepalive-core/src/main/AndroidManifest.xml | 4 + .../main/assets/legitkeepalive}/default.json | 0 .../main/assets/legitkeepalive}/honor.json | 0 .../main/assets/legitkeepalive}/xiaomi.json | 0 .../legitkeepalive/KeepAliveRecommendation.kt | 26 ++ .../ityulong/legitkeepalive/LegitKeepAlive.kt | 98 ++++++ .../legitkeepalive/OpenSettingsResult.kt | 10 + .../legitkeepalive/internal/ConfigModels.kt | 43 +++ .../legitkeepalive/internal/RuleResolver.kt | 55 ++++ .../internal/SettingsIntentFactory.kt | 51 ++++ .../internal/SettingsRepository.kt | 41 +++ .../legitkeepalive/internal/StatusChecker.kt | 51 ++++ .../internal/SystemPropertyReader.kt | 12 + .../legitkeepalive/LegitKeepAliveTest.kt | 54 ++++ .../internal/RuleResolverTest.kt | 99 ++++++ .../internal/SettingsIntentFactoryTest.kt | 55 ++++ settings.gradle.kts | 2 +- 54 files changed, 1016 insertions(+), 1795 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md delete mode 100644 app/src/androidTest/java/com/ght/keepalive/ExampleInstrumentedTest.kt delete mode 100644 app/src/main/java/com/ght/keepalive/MainViewModel.kt delete mode 100644 app/src/main/java/com/ght/keepalive/MyApplication.kt delete mode 100644 app/src/main/java/com/ght/keepalive/enumid/SettingId.kt delete mode 100644 app/src/main/java/com/ght/keepalive/model/KeepAliveConfig.kt delete mode 100644 app/src/main/java/com/ght/keepalive/permission/PermissionConverter.java delete mode 100644 app/src/main/java/com/ght/keepalive/permission/PermissionDescription.java delete mode 100644 app/src/main/java/com/ght/keepalive/permission/PermissionInterceptor.java delete mode 100644 app/src/main/java/com/ght/keepalive/permission/WindowLifecycleManager.java delete mode 100644 app/src/main/java/com/ght/keepalive/util/OemPermissionHelper.kt delete mode 100644 app/src/main/java/com/ght/keepalive/util/StatusCheckerRegistry.kt delete mode 100644 app/src/main/java/com/ght/keepalive/util/SystemUtils.kt delete mode 100644 app/src/main/java/com/ght/keepalive/util/ToastUtil.kt delete mode 100644 app/src/main/java/com/ght/repository/SettingsRepository.kt delete mode 100644 app/src/main/res/drawable/permission_description_popup_bg.xml delete mode 100644 app/src/main/res/layout/permission_description_popup.xml delete mode 100644 app/src/test/java/com/ght/keepalive/ExampleUnitTest.kt create mode 100644 docs/releases/v0.1.0.md create mode 100644 jitpack.yml create mode 100644 keepalive-core/build.gradle.kts create mode 100644 keepalive-core/consumer-rules.pro create mode 100644 keepalive-core/src/main/AndroidManifest.xml rename {app/src/main/assets => keepalive-core/src/main/assets/legitkeepalive}/default.json (100%) rename {app/src/main/assets => keepalive-core/src/main/assets/legitkeepalive}/honor.json (100%) rename {app/src/main/assets => keepalive-core/src/main/assets/legitkeepalive}/xiaomi.json (100%) create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/KeepAliveRecommendation.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/LegitKeepAlive.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/OpenSettingsResult.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/ConfigModels.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/RuleResolver.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactory.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsRepository.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/StatusChecker.kt create mode 100644 keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SystemPropertyReader.kt create mode 100644 keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/LegitKeepAliveTest.kt create mode 100644 keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/RuleResolverTest.kt create mode 100644 keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactoryTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..cdb8a60 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,40 @@ +name: Android CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Verify library and sample + run: ./gradlew test lint assembleRelease + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-build-reports + path: | + **/build/reports/** + **/build/outputs/lint-results-*.html + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..26fff96 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Publish Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Verify and build release artifacts + run: ./gradlew test lint :keepalive-core:assembleRelease :app:assembleDebug + + - name: Prepare release artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p dist + cp keepalive-core/build/outputs/aar/keepalive-core-release.aar "dist/legitkeepalive-${VERSION}.aar" + cp app/build/outputs/apk/debug/app-debug.apk "dist/legitkeepalive-sample-${VERSION}.apk" + cd dist + sha256sum *.aar *.apk > SHA256SUMS.txt + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" dist/* --notes-file "docs/releases/${GITHUB_REF_NAME}.md" --title "LegitKeepAlive $GITHUB_REF_NAME" diff --git a/.gitignore b/.gitignore index 16dcd12..f7169b6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ local.properties # Log/OS Files *.log +.DS_Store # Android Studio generated files and folders captures/ @@ -30,4 +31,4 @@ render.experimental.xml google-services.json # Android Profiling -*.hprof \ No newline at end of file +*.hprof diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49a8152 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-06-04 + +### Added + +- Reusable `keepalive-core` Android Library. +- Public `LegitKeepAlive` API for recommendations, status checks, intent creation, and settings navigation. +- Configuration validation and ROM-specific rule resolution. +- Generic Android, Xiaomi MIUI 12, and Honor MagicOS 9.x bundled rules. +- Unit and Robolectric test coverage for core behavior. +- Maven publication, JitPack configuration, CI, and automated GitHub Releases. +- Sample app that consumes only the public library API. + +[Unreleased]: https://github.com/ityulong/LegitKeepAlive/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ityulong/LegitKeepAlive/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6dc4ba6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Contributions that improve real-device compatibility, correctness, tests, or documentation are welcome. + +## Before Opening An Issue + +Include: + +- device manufacturer and model +- Android version and OEM ROM version +- the affected recommendation +- whether navigation, status detection, or both failed +- screenshots and relevant logs when available + +Do not include private user data or propose techniques that bypass Android platform policy. + +## Development + +1. Create a focused branch. +2. Add or update tests for behavioral changes. +3. Update `docs/oem-support-matrix.md` when OEM coverage changes. +4. Run: + +```bash +./gradlew clean test lint assembleRelease +``` + +## OEM Rule Changes + +OEM rules live in `keepalive-core/src/main/assets/legitkeepalive/`. + +- Prefer configuration changes over new OEM-specific code. +- Preserve the generic fallback behavior. +- Confirm settings components on real hardware where possible. +- Mark a recommendation as checkable only when its state can be determined reliably. +- Explain the validation device and ROM version in the pull request. + +## Pull Requests + +Keep pull requests focused and describe: + +- the problem and affected devices +- the chosen implementation +- verification performed +- compatibility or API impact + +By contributing, you agree that your contribution is licensed under the Apache License 2.0. diff --git a/README.md b/README.md index 97d44c2..4a003fb 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,120 @@ # LegitKeepAlive -An open-source Android toolkit for lawful background reliability on fragmented OEM ROMs. +[![Android CI](https://github.com/ityulong/LegitKeepAlive/actions/workflows/android.yml/badge.svg)](https://github.com/ityulong/LegitKeepAlive/actions/workflows/android.yml) +[![Release](https://img.shields.io/github/v/release/ityulong/LegitKeepAlive)](https://github.com/ityulong/LegitKeepAlive/releases) +[![License](https://img.shields.io/github/license/ityulong/LegitKeepAlive)](LICENSE) -`LegitKeepAlive` helps apps keep notifications, location updates, sync jobs, and scheduled work running more reliably on heavily customized Android systems without private APIs, process abuse, or undocumented keep-alive tricks. +`LegitKeepAlive` is a reusable Android library for policy-compliant background reliability guidance on fragmented OEM ROMs. -本项目面向 Android 碎片化厂商生态,提供一套“合法保活”方案:通过检测厂商与系统版本、加载配置化规则、检查权限状态,并引导用户跳转到正确的系统设置页面,提升后台运行相关能力的可用性。 +It detects the current manufacturer and ROM version, resolves configuration-driven recommendations, checks settings when Android exposes a reliable API, and opens the appropriate system settings page. It does not bypass Android policies, use private APIs, or silently change user settings. -## Why This Project Exists +## Features -Android developers often hit the same production problem on OEM ROMs: +- Reusable `keepalive-core` Android Library with a small public API. +- Configuration-driven OEM and ROM-specific rules. +- Status checks for supported Android and OEM settings. +- Non-throwing settings navigation results. +- Generic Android fallback plus Xiaomi MIUI 12 and Honor MagicOS 9.x rules. +- Unit and Robolectric tests for rule resolution, configuration validity, and intent creation. -- background tasks are throttled aggressively -- notifications become unreliable -- location, sync, and scheduled work stop after the app is killed -- every vendor exposes different settings pages and permission models +## Install -Most workarounds in the wild rely on undocumented behavior or user-hostile hacks. `LegitKeepAlive` takes the opposite path: +The first formal API release is `v0.1.0`. Add JitPack to `settings.gradle.kts`: -- no private APIs -- no hidden background execution tricks -- no fake foreground abuse -- no bypass of platform policy - -Instead, it provides a reusable compatibility layer that helps developers guide users to the right settings in a transparent and maintainable way. - -## What It Does - -- Detects OEM and relevant system version details. -- Loads JSON-based vendor rules from `assets/`. -- Matches the most appropriate settings flow for the current device. -- Checks the status of standard and OEM-specific permissions where possible. -- Deep-links users into the correct settings pages. -- Keeps the adaptation layer configuration-driven, so new vendors and ROM versions can be added without rewriting the whole app. - -## Project Highlights - -- Configuration-driven OEM support -- MVVM-based Android app structure -- Deep-link navigation to vendor settings pages -- Permission and status inspection helpers -- Extensible vendor adaptation model -- Public base for future issue triage and compatibility tooling - -## Supported OEM Coverage - -Current public support is documented in [docs/oem-support-matrix.md](docs/oem-support-matrix.md). - -At the moment, the repository includes: - -- Xiaomi / MIUI flows -- Honor / Magic OS flows -- Default Android fallback flows +```kotlin +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven("https://jitpack.io") + } +} +``` -## Repository Structure +Then add the library: -```text -. -├── app/ -│ ├── src/main/assets/ # OEM JSON rules -│ ├── src/main/java/com/ght/ -│ │ ├── keepalive/ # UI, models, utils, adapters -│ │ └── repository/ # config loading and data access -│ └── src/main/res/ # layouts, strings, themes, drawables -├── docs/ # support matrix and maintainer docs -├── gradle/ # wrapper and version catalog -└── README.md +```kotlin +dependencies { + implementation("com.github.ityulong:LegitKeepAlive:v0.1.0") +} ``` -## How It Works - -1. The app identifies the current OEM using `Build.MANUFACTURER`. -2. `SettingsRepository` loads the matching JSON file from `assets/`. -3. `MainViewModel` resolves version-specific entries when present. -4. `MainActivity` renders the checklist-style UI through `RecyclerView`. -5. `StatusCheckerRegistry` evaluates what is already enabled. -6. Clicking an item builds an `Intent` from the JSON rule and opens the target settings page. +The release page also provides the AAR and a sample APK. The Maven publication generated by the project uses `io.github.ityulong:legitkeepalive:0.1.0`. -## Tech Stack +## Usage -- Kotlin + Android SDK -- MVVM -- AndroidX Lifecycle -- Kotlin Serialization -- XXPermissions -- Timber +```kotlin +private val keepAlive by lazy { + LegitKeepAlive.create(applicationContext) +} -Minimum SDK is currently `26`. +val recommendations = keepAlive.recommendations() -## Running The Demo +recommendations.forEach { recommendation -> + val enabled: Boolean? = keepAlive.isEnabled(recommendation) + val result = keepAlive.openSettings(this, recommendation) +} +``` -1. Open the project in Android Studio. -2. Sync Gradle dependencies. -3. Run the `app` module on a physical Android device. -4. Test on OEM devices when possible, because vendor behavior differs significantly from emulators. +`isEnabled()` returns `null` when the setting cannot be checked reliably. `openSettings()` returns `OPENED`, `UNSUPPORTED`, or `FAILED` instead of throwing when an OEM settings page is unavailable. -## Integrating Into Your Own App +You can inspect an intent before opening it: -If you want to reuse the approach in another Android project: +```kotlin +val intent = keepAlive.createSettingsIntent(recommendation) +``` -1. Copy the core modules related to models, repository, utilities, status checking, and UI flow. -2. Copy the JSON files under `app/src/main/assets/`. -3. Wire your own entry page to a `ViewModel` similar to `MainViewModel`. -4. Extend `StatusCheckerRegistry` for any new permission or OEM-specific check. -5. Add or refine vendor JSON configs as new devices are reported. +## Optional Permissions -## Adding A New OEM Or ROM Version +The library only declares `ACCESS_NETWORK_STATE`. Add the permissions required by the recommendations you present: -The intended workflow is: +```xml + + +``` -1. Create or extend an OEM JSON file in `app/src/main/assets/`. -2. Add version-specific settings entries if the ROM changes navigation paths. -3. Add or update status-check logic when the setting can be programmatically verified. -4. Validate the navigation path on real hardware. -5. Document the result in [docs/oem-support-matrix.md](docs/oem-support-matrix.md). +These are special-access flows controlled by the user. Applications should explain why they are needed and request only what is necessary for a real feature. -## Maintainer Workflow +## Supported OEMs -This repository is being shaped toward a real maintainer workflow for fragmented Android compatibility work: +| OEM | ROM / System | Coverage | +| --- | --- | --- | +| Generic Android | Default fallback | Implemented | +| Xiaomi | MIUI 12 | Implemented | +| Honor | MagicOS 9.x | Implemented | -- structured OEM bug reports -- reproducible device metadata collection -- config-driven vendor adaptation -- public compatibility tracking -- future automation for issue triage and config proposal review +OEM settings components can change between device and ROM releases. See the [support matrix](docs/oem-support-matrix.md) for validation expectations and known coverage. -To support that, the repo now includes: +## Repository Structure -- bug report templates tailored for OEM / ROM debugging -- feature request templates for new vendor support -- a pull request template for compatibility changes +```text +. +├── keepalive-core/ # Reusable Android Library and bundled OEM rules +├── app/ # Sample app using only the public library API +├── docs/ # Support matrix, roadmap, plans, and release notes +└── .github/ # Issue templates and release/CI workflows +``` -## Roadmap +## Build And Verify -The current roadmap is documented in [docs/maintainer-roadmap.md](docs/maintainer-roadmap.md). +```bash +./gradlew clean test lint assembleRelease +./gradlew :keepalive-core:publishReleasePublicationToMavenLocal +``` -Near-term priorities: +The sample app is intended for testing navigation on physical OEM devices: -- expand OEM coverage beyond Xiaomi and Honor -- improve version-specific rule matching -- strengthen status-check coverage -- collect reproducible device reports in a consistent format -- add maintainer automation around compatibility triage +```bash +./gradlew :app:assembleDebug +``` ## Contributing -Contributions are welcome, especially for: - -- OEM-specific settings paths -- ROM version verification -- permission status checks -- documentation improvements -- issue reproduction details from real devices - -Before opening a PR: - -- confirm the device brand and ROM version -- include screenshots when a settings path changes -- describe whether the setting was only navigable or also programmatically checkable -- update the support matrix when behavior changes - -## Filing Issues - -When reporting a compatibility problem, please include: - -- device brand and model -- Android version -- OEM ROM version -- target settings page or screenshot -- whether navigation failed, status detection failed, or both -- any relevant logs - -Using the included GitHub issue template will make triage much faster. +Real-device reports and OEM rule improvements are especially useful. Read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a change and update the [support matrix](docs/oem-support-matrix.md) when compatibility changes. -## Open Source Status +## Scope -This repository is public and maintained as an open-source compatibility toolkit intended to reduce repeated OEM adaptation work across Android teams. +This project improves transparent user guidance. It is not a replacement for WorkManager, foreground services, push messaging, or correct Android lifecycle architecture. It intentionally does not implement deceptive or abusive keep-alive techniques. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f926f06 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to the latest released version. + +## Reporting A Vulnerability + +Do not disclose an unpatched vulnerability in a public issue. Report it through GitHub's private vulnerability reporting feature: + +https://github.com/ityulong/LegitKeepAlive/security/advisories/new + +Include the affected version, reproduction steps, impact, and any suggested mitigation. Reports involving deceptive background execution, permission bypasses, or policy evasion will not be accepted as feature requests. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aeca171..5140d8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,10 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" - } +val libraryVersion = providers.gradleProperty("VERSION_NAME").get() + android { namespace = "com.ght.keepalive" compileSdk = 34 @@ -14,9 +14,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + versionName = libraryVersion } buildTypes { @@ -29,33 +27,19 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } dependencies { - + implementation(project(":keepalive-core")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - - implementation("com.github.getActivity:XXPermissions:23.0") - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") - - implementation("com.jakewharton.timber:timber:5.0.1") - - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.androidx.lifecycle.livedata.ktx) - implementation(libs.androidx.activity.ktx) - -} \ No newline at end of file +} diff --git a/app/src/androidTest/java/com/ght/keepalive/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/ght/keepalive/ExampleInstrumentedTest.kt deleted file mode 100644 index 3f36e6f..0000000 --- a/app/src/androidTest/java/com/ght/keepalive/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.ght.keepalive - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.ght.keepalive", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9a5703b..81d4be2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,6 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/ght/keepalive/MainActivity.kt b/app/src/main/java/com/ght/keepalive/MainActivity.kt index c325020..48595d3 100644 --- a/app/src/main/java/com/ght/keepalive/MainActivity.kt +++ b/app/src/main/java/com/ght/keepalive/MainActivity.kt @@ -1,30 +1,19 @@ package com.ght.keepalive -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.widget.Toast -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.ght.keepalive.adapt.KeepAliveAdapter -import com.ght.keepalive.enumid.SettingId -import com.ght.keepalive.model.SettingItem -import com.ght.keepalive.permission.PermissionConverter -import com.ght.keepalive.permission.PermissionInterceptor -import com.ght.keepalive.util.StatusCheckerRegistry -import com.ght.keepalive.util.ToastUtil -import com.hjq.permissions.OnPermissionCallback -import com.hjq.permissions.XXPermissions -import timber.log.Timber +import io.github.ityulong.legitkeepalive.KeepAliveRecommendation +import io.github.ityulong.legitkeepalive.LegitKeepAlive +import io.github.ityulong.legitkeepalive.OpenSettingsResult class MainActivity : AppCompatActivity() { private lateinit var recyclerView: RecyclerView - private val viewModel: MainViewModel by viewModels() + private val keepAlive by lazy { LegitKeepAlive.create(applicationContext) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,86 +21,27 @@ class MainActivity : AppCompatActivity() { recyclerView = findViewById(R.id.recyclerView) recyclerView.layoutManager = LinearLayoutManager(this) - viewModel.settings.observe(this) { settings -> - if (settings.isNotEmpty()) { - recyclerView.adapter = KeepAliveAdapter( - settings, - this::checkSettingStatus - ) { settingItem -> - executeNavigation(settingItem) - } - } else { - recyclerView.adapter = null - } - } - - viewModel.error.observe(this) { errorMessage -> - Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() - } } override fun onResume() { super.onResume() - viewModel.loadSettings() + val recommendations = keepAlive.recommendations() + recyclerView.adapter = KeepAliveAdapter( + recommendations, + keepAlive::isEnabled, + this::openSettings + ) } - private fun checkSettingStatus(settingId: SettingId): Boolean { - return StatusCheckerRegistry.check(settingId, this) - } - - private fun executeNavigation(setting: SettingItem) { - try { - setting.intent.permission?.let { - - XXPermissions.with(this) - .permission(it) - .interceptor(PermissionInterceptor()) - .request(OnPermissionCallback { permissions, allGranted -> - if (!allGranted) { - return@OnPermissionCallback - } - ToastUtil.showShort( - String.format( - getString(R.string.demo_obtain_permission_success_hint), - PermissionConverter.getNamesByPermissions(this, permissions) - ) - ) - }) - return - } - val intent = Intent() - val intentInfo = setting.intent - if (intentInfo.action != null) { - intent.action = intentInfo.action - } - if (intentInfo.component != null) { - val componentInfo = intentInfo.component.split("/") - intent.component = ComponentName(componentInfo[0], componentInfo[1]) - } - if (intentInfo.isAllNullOrEmpty) { - Timber.e("intent info data is null!") - Toast.makeText(this, "跳转指令配置错误", Toast.LENGTH_SHORT).show() - return - } - intent.putExtra("package_name", packageName) - intent.putExtra("extra_pkgname", packageName) - intent.putExtra("package_label", getAppName(this)) - intent.setData(Uri.fromParts("package", packageName, null)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } catch (e: Exception) { - e.printStackTrace() - Timber.d( "无法打开该设置界面,您的系统可能不支持", Toast.LENGTH_LONG) - XXPermissions.startPermissionActivity(this) + private fun openSettings(recommendation: KeepAliveRecommendation) { + when (keepAlive.openSettings(this, recommendation)) { + OpenSettingsResult.OPENED -> Unit + OpenSettingsResult.UNSUPPORTED -> showMessage(R.string.settings_unsupported) + OpenSettingsResult.FAILED -> showMessage(R.string.settings_open_failed) } } - private fun getAppName(context: Context): String { - try { - return context.applicationInfo.loadLabel(context.packageManager).toString() - } catch (e: java.lang.Exception) { - Timber.e("Failed to get app name.") - return "" // 返回空字符串作为后备 - } + private fun showMessage(messageRes: Int) { + Toast.makeText(this, messageRes, Toast.LENGTH_SHORT).show() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ght/keepalive/MainViewModel.kt b/app/src/main/java/com/ght/keepalive/MainViewModel.kt deleted file mode 100644 index 9f59629..0000000 --- a/app/src/main/java/com/ght/keepalive/MainViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.ght.keepalive - -import android.app.Application -import android.os.Build -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.ght.keepalive.model.ManufacturerConfig -import com.ght.keepalive.model.SettingItem -import com.ght.keepalive.util.SystemUtils -import com.ght.repository.SettingsRepository -import timber.log.Timber - -class MainViewModel(application: Application) : AndroidViewModel(application) { - - private val repository = SettingsRepository(application) - - private val _settings = MutableLiveData>() - val settings: LiveData> = _settings - - private val _error = MutableLiveData() - val error: LiveData = _error - - fun loadSettings() { - val manufacturer = Build.MANUFACTURER.lowercase() - val manufacturerConfig = repository.loadManufacturerConfig(manufacturer) - - if (manufacturerConfig == null) { - _error.value = "配置文件加载失败" - return - } - - val relevantSettings = getRelevantSettings(manufacturerConfig, manufacturer) - - if (relevantSettings.isEmpty()) { - _error.value = "没有找到适用于您设备的优化项" - _settings.value = emptyList() - } else { - _settings.value = relevantSettings - } - } - - private fun getRelevantSettings( - manufacturerConfig: ManufacturerConfig, - manufacturer: String - ): List { - val osVersionProperty = manufacturerConfig.os_version_property - if (osVersionProperty.isNullOrEmpty()) { - return manufacturerConfig.default_settings - } - - val actualVersion = SystemUtils.getSystemProperty(osVersionProperty) - Timber.d("finding setting item by:$manufacturer,$actualVersion") - if (actualVersion.isNullOrEmpty()) { - return manufacturerConfig.default_settings - } - - val versionSpecificSetting = manufacturerConfig.version_specifics?.find { - val regex = Regex(it.version_matches_regex) - regex.matches(actualVersion) - } - - - val specificItems = versionSpecificSetting?.settings ?: manufacturerConfig.default_settings - // 合并并去重,确保每个 id 只出现一次 - return (specificItems).distinctBy { it.id } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/MyApplication.kt b/app/src/main/java/com/ght/keepalive/MyApplication.kt deleted file mode 100644 index ed766b7..0000000 --- a/app/src/main/java/com/ght/keepalive/MyApplication.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ght.keepalive - -import android.app.Application -import timber.log.Timber - -class MyApplication : Application() { - companion object { - lateinit var instance: MyApplication - private set - } - - override fun onCreate() { - super.onCreate() - instance = this - Timber.plant(Timber.DebugTree()) - } -} diff --git a/app/src/main/java/com/ght/keepalive/adapt/KeepAliveAdapter.kt b/app/src/main/java/com/ght/keepalive/adapt/KeepAliveAdapter.kt index 551304a..8b20908 100644 --- a/app/src/main/java/com/ght/keepalive/adapt/KeepAliveAdapter.kt +++ b/app/src/main/java/com/ght/keepalive/adapt/KeepAliveAdapter.kt @@ -9,13 +9,12 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView import com.ght.keepalive.R -import com.ght.keepalive.enumid.SettingId -import com.ght.keepalive.model.SettingItem +import io.github.ityulong.legitkeepalive.KeepAliveRecommendation class KeepAliveAdapter( - private val items: List, - private val statusChecker: (SettingId) -> Boolean, - private val onItemClick: (SettingItem) -> Unit + private val items: List, + private val statusChecker: (KeepAliveRecommendation) -> Boolean?, + private val onItemClick: (KeepAliveRecommendation) -> Unit ) : RecyclerView.Adapter() { class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { @@ -37,20 +36,17 @@ class KeepAliveAdapter( holder.title.text = item.title holder.description.text = item.description - if (item.is_checkable) { + val isEnabled = statusChecker(item) + if (isEnabled != null) { holder.checkbox.visibility = View.VISIBLE holder.arrow.visibility = View.GONE - - val isEnabled = statusChecker(item.id) holder.checkbox.isChecked = isEnabled - - holder.container.setOnClickListener { onItemClick(item) } } else { holder.checkbox.visibility = View.GONE holder.arrow.visibility = View.VISIBLE - holder.container.setOnClickListener { onItemClick(item) } } + holder.container.setOnClickListener { onItemClick(item) } } override fun getItemCount() = items.size -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ght/keepalive/enumid/SettingId.kt b/app/src/main/java/com/ght/keepalive/enumid/SettingId.kt deleted file mode 100644 index 64d939d..0000000 --- a/app/src/main/java/com/ght/keepalive/enumid/SettingId.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.ght.keepalive.enumid - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * 使用枚举统一管理所有的设置项 ID,彻底消除魔术字符串。 - * 每个枚举成员都通过 @SerialName 注解与 JSON 文件中的字符串值精确对应。 - */ -@Serializable -enum class SettingId { - - // --- 通用设置 --- - - @SerialName("battery_optimization") - BATTERY_OPTIMIZATION, - - @SerialName("background_data") - BACKGROUND_DATA, - - @SerialName("alert_window") - ALERT_WINDOW, - - // --- 小米专属设置 --- - - @SerialName("xiaomi_power_saving_strategy") - XIAOMI_POWER_SAVING_STRATEGY, - - @SerialName("xiaomi_background_popup") - XIAOMI_BACKGROUND_POPUP, - - @SerialName("xiaomi_lock_popup") - XIAOMI_LOCK_POPUP, - - // --- 荣耀/华为专属设置 --- - - - @SerialName("background_activity") - BACKGROUND_ACTIVITY, - - - // ✨ 未来如果 JSON 文件中新增任何厂商或新的 id, - // ✨ 只需在这里添加对应的枚举成员即可。 -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/model/KeepAliveConfig.kt b/app/src/main/java/com/ght/keepalive/model/KeepAliveConfig.kt deleted file mode 100644 index 093bd8d..0000000 --- a/app/src/main/java/com/ght/keepalive/model/KeepAliveConfig.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.ght.keepalive.model - -import com.ght.keepalive.enumid.SettingId -import kotlinx.serialization.Serializable - -// AllConfigs 已被移除 - -@Serializable -data class ManufacturerConfig( - val comment: String, - val os_version_property: String? = null, - val default_settings: List, - val version_specifics: List? = null -) - -@Serializable -data class VersionSpecific( - val version_matches_regex: String, - val comment: String, - val settings: List -) - -@Serializable -data class SettingItem( - val id: SettingId, - val title: String, - val description: String, - val is_checkable: Boolean = false, // 包含默认值的关键字段 - val intent: IntentInfo -) - -@Serializable -data class IntentInfo( - val action: String? = null, - val permission: String? = null, - val component: String? = null -){ - /** - * 如果你认为 null 和空字符串 "" 都算“空”,可以使用这个版本。 - * 这种方式更健壮。 - */ - val isAllNullOrEmpty: Boolean - get() = action.isNullOrEmpty() && permission.isNullOrEmpty() && component.isNullOrEmpty() - -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/permission/PermissionConverter.java b/app/src/main/java/com/ght/keepalive/permission/PermissionConverter.java deleted file mode 100644 index f60c54e..0000000 --- a/app/src/main/java/com/ght/keepalive/permission/PermissionConverter.java +++ /dev/null @@ -1,288 +0,0 @@ -package com.ght.keepalive.permission; - -import android.content.Context; -import android.os.Build; -import android.text.TextUtils; - -import com.ght.keepalive.R; -import com.hjq.permissions.Permission; -import com.hjq.permissions.XXPermissions; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import androidx.annotation.NonNull; - -/** - * author : Android 轮子哥 - * github : https://github.com/getActivity/XXPermissions - * time : 2025/05/30 - * desc : 权限转换器(根据权限获取对应的名称和说明) - */ -public final class PermissionConverter { - - /** 权限名称映射(为了适配多语种,这里存储的是 StringId,而不是 String) */ - private static final Map PERMISSION_NAME_MAP = new HashMap<>(); - - /** 权限描述映射(为了适配多语种,这里存储的是 StringId,而不是 String) */ - private static final Map PERMISSION_DESCRIPTION_MAP = new HashMap<>(); - - static { - PERMISSION_NAME_MAP.put(Permission.READ_EXTERNAL_STORAGE, R.string.common_permission_storage); - PERMISSION_NAME_MAP.put(Permission.WRITE_EXTERNAL_STORAGE, R.string.common_permission_storage); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_storage, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.READ_MEDIA_IMAGES, R.string.common_permission_image_and_video); - PERMISSION_NAME_MAP.put(Permission.READ_MEDIA_VIDEO, R.string.common_permission_image_and_video); - PERMISSION_NAME_MAP.put(Permission.READ_MEDIA_VISUAL_USER_SELECTED, R.string.common_permission_image_and_video); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_image_and_video, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.READ_MEDIA_AUDIO, R.string.common_permission_music_and_audio); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_music_and_audio, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.CAMERA, R.string.common_permission_camera); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_camera, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.RECORD_AUDIO, R.string.common_permission_microphone); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_microphone, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.ACCESS_FINE_LOCATION, R.string.common_permission_location); - PERMISSION_NAME_MAP.put(Permission.ACCESS_COARSE_LOCATION, R.string.common_permission_location); - - // 注意:在 Android 12 的时候,蓝牙相关的权限已经归到附近设备的权限组了,但是在 Android 12 之前,蓝牙相关的权限归属定位权限组 - if (Build.VERSION.SDK_INT >= 30) { - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_SCAN, R.string.common_permission_nearby_devices); - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_CONNECT, R.string.common_permission_nearby_devices); - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_ADVERTISE, R.string.common_permission_nearby_devices); - // 注意:在 Android 13 的时候,WIFI 相关的权限已经归到附近设备的权限组了,但是在 Android 13 之前,WIFI 相关的权限归属定位权限组 - if (Build.VERSION.SDK_INT >= 33) { - PERMISSION_NAME_MAP.put(Permission.NEARBY_WIFI_DEVICES, R.string.common_permission_nearby_devices); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_nearby_devices, R.string.common_permission_description_demo); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_location, R.string.common_permission_description_demo); - } else { - PERMISSION_NAME_MAP.put(Permission.NEARBY_WIFI_DEVICES, R.string.common_permission_location); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_nearby_devices, R.string.common_permission_description_demo); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_location, R.string.common_permission_description_demo); - } - } else { - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_SCAN, R.string.common_permission_location); - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_CONNECT, R.string.common_permission_location); - PERMISSION_NAME_MAP.put(Permission.BLUETOOTH_ADVERTISE, R.string.common_permission_location); - PERMISSION_NAME_MAP.put(Permission.NEARBY_WIFI_DEVICES, R.string.common_permission_location); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_location, R.string.common_permission_description_demo); - } - - PERMISSION_NAME_MAP.put(Permission.ACCESS_BACKGROUND_LOCATION, R.string.common_permission_location_background); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_location_background, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.BODY_SENSORS, R.string.common_permission_body_sensors); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_body_sensors, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.BODY_SENSORS_BACKGROUND, R.string.common_permission_body_sensors_background); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_body_sensors_background, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.READ_PHONE_STATE, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.CALL_PHONE, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.ADD_VOICEMAIL, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.USE_SIP, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.READ_PHONE_NUMBERS, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.ANSWER_PHONE_CALLS, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.ACCEPT_HANDOVER, R.string.common_permission_phone); - // 注意:在 Android 9.0 的时候,读写通话记录权限已经归到一个单独的权限组了,但是在 Android 9.0 之前,读写通话记录权限归属电话权限组 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - PERMISSION_NAME_MAP.put(Permission.READ_CALL_LOG, R.string.common_permission_call_logs); - PERMISSION_NAME_MAP.put(Permission.WRITE_CALL_LOG, R.string.common_permission_call_logs); - PERMISSION_NAME_MAP.put(Permission.PROCESS_OUTGOING_CALLS, R.string.common_permission_call_logs); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_call_logs, R.string.common_permission_description_demo); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_phone, R.string.common_permission_description_demo); - } else { - PERMISSION_NAME_MAP.put(Permission.READ_CALL_LOG, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.WRITE_CALL_LOG, R.string.common_permission_phone); - PERMISSION_NAME_MAP.put(Permission.PROCESS_OUTGOING_CALLS, R.string.common_permission_phone); - // 需要注意:这里的电话权限需要补充一下前面三个通话记录权限的用途 - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_phone, R.string.common_permission_description_demo); - } - - PERMISSION_NAME_MAP.put(Permission.GET_ACCOUNTS, R.string.common_permission_contacts); - PERMISSION_NAME_MAP.put(Permission.READ_CONTACTS, R.string.common_permission_contacts); - PERMISSION_NAME_MAP.put(Permission.WRITE_CONTACTS, R.string.common_permission_contacts); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_contacts, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.READ_CALENDAR, R.string.common_permission_calendar); - PERMISSION_NAME_MAP.put(Permission.WRITE_CALENDAR, R.string.common_permission_calendar); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_calendar, R.string.common_permission_description_demo); - - // 注意:在 Android 10 的版本,这个权限的名称为《健身运动权限》,但是到了 Android 11 的时候,这个权限的名称被修改成了《身体活动权限》 - // 没错就改了一下权限的叫法,其他的一切没有变,Google 产品经理真的是闲的蛋疼,但是吐槽归吐槽,框架也要灵活应对一下,避免小白用户跳转到设置页找不到对应的选项 - int activityRecognitionPermissionNameStringId = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? R.string.common_permission_activity_recognition_api30 : R.string.common_permission_activity_recognition_api29; - PERMISSION_NAME_MAP.put(Permission.ACTIVITY_RECOGNITION, activityRecognitionPermissionNameStringId); - PERMISSION_DESCRIPTION_MAP.put(activityRecognitionPermissionNameStringId, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.ACCESS_MEDIA_LOCATION, R.string.common_permission_access_media_location); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_access_media_location, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.SEND_SMS, R.string.common_permission_sms); - PERMISSION_NAME_MAP.put(Permission.RECEIVE_SMS, R.string.common_permission_sms); - PERMISSION_NAME_MAP.put(Permission.READ_SMS, R.string.common_permission_sms); - PERMISSION_NAME_MAP.put(Permission.RECEIVE_WAP_PUSH, R.string.common_permission_sms); - PERMISSION_NAME_MAP.put(Permission.RECEIVE_MMS, R.string.common_permission_sms); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_sms, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.GET_INSTALLED_APPS, R.string.common_permission_get_installed_apps); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_get_installed_apps, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.MANAGE_EXTERNAL_STORAGE, R.string.common_permission_all_file_access); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_all_file_access, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.REQUEST_INSTALL_PACKAGES, R.string.common_permission_install_unknown_apps); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_install_unknown_apps, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.SYSTEM_ALERT_WINDOW, R.string.common_permission_display_over_other_apps); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_display_over_other_apps, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.WRITE_SETTINGS, R.string.common_permission_modify_system_settings); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_modify_system_settings, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.NOTIFICATION_SERVICE, R.string.common_permission_allow_notifications); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_allow_notifications, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.POST_NOTIFICATIONS, R.string.common_permission_post_notifications); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_post_notifications, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.BIND_NOTIFICATION_LISTENER_SERVICE, R.string.common_permission_allow_notifications_access); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_allow_notifications_access, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.PACKAGE_USAGE_STATS, R.string.common_permission_apps_with_usage_access); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_apps_with_usage_access, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.SCHEDULE_EXACT_ALARM, R.string.common_permission_alarms_reminders); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_alarms_reminders, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.ACCESS_NOTIFICATION_POLICY, R.string.common_permission_do_not_disturb_access); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_do_not_disturb_access, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, R.string.common_permission_ignore_battery_optimize); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_ignore_battery_optimize, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.BIND_VPN_SERVICE, R.string.common_permission_vpn); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_vpn, R.string.common_permission_description_demo); - - PERMISSION_NAME_MAP.put(Permission.PICTURE_IN_PICTURE, R.string.common_permission_picture_in_picture); - PERMISSION_DESCRIPTION_MAP.put(R.string.common_permission_picture_in_picture, R.string.common_permission_description_demo); - } - - /** - * 通过权限获得名称 - */ - @NonNull - public static String getNamesByPermissions(@NonNull Context context, @NonNull List permissions) { - List permissionNameList = getNameListByPermissions(context, permissions, true); - - StringBuilder builder = new StringBuilder(); - for (String permissionName : permissionNameList) { - if (TextUtils.isEmpty(permissionName)) { - continue; - } - if (builder.length() == 0) { - builder.append(permissionName); - } else { - builder.append(context.getString(R.string.common_permission_comma)) - .append(permissionName); - } - } - if (builder.length() == 0) { - // 如果没有获得到任何信息,则返回一个默认的文本 - return context.getString(R.string.common_permission_unknown); - } - return builder.toString(); - } - - @NonNull - public static List getNameListByPermissions(@NonNull Context context, @NonNull List permissions, boolean filterHighVersionPermissions) { - List permissionNameList = new ArrayList<>(); - for (String permission : permissions) { - // 如果当前设置了过滤高版本权限,并且这个权限是高版本系统才出现的权限,则不继续往下执行 - // 避免出现在低版本上面执行拒绝权限后,连带高版本的名称也一起显示出来,但是在低版本上面是没有这个权限的 - if (filterHighVersionPermissions && XXPermissions.isHighVersionPermission(permission)) { - continue; - } - String permissionName = getNameByPermission(context, permission); - if (TextUtils.isEmpty(permissionName)) { - continue; - } - if (permissionNameList.contains(permissionName)) { - continue; - } - permissionNameList.add(permissionName); - } - return permissionNameList; - } - - public static String getNameByPermission(@NonNull Context context, @NonNull String permission) { - Integer permissionNameStringId = PERMISSION_NAME_MAP.get(permission); - if (permissionNameStringId == null || permissionNameStringId == 0) { - return ""; - } - return context.getString(permissionNameStringId); - } - - /** - * 通过权限获得描述 - */ - @NonNull - public static String getDescriptionsByPermissions(@NonNull Context context, @NonNull List permissions) { - List descriptionList = getDescriptionListByPermissions(context, permissions); - - StringBuilder builder = new StringBuilder(); - for (String description : descriptionList) { - if (TextUtils.isEmpty(description)) { - continue; - } - if (builder.length() == 0) { - builder.append(description); - } else { - builder.append("\n") - .append(description); - } - } - return builder.toString(); - } - - @NonNull - public static List getDescriptionListByPermissions(@NonNull Context context, @NonNull List permissions) { - List descriptionList = new ArrayList<>(); - for (String permission : permissions) { - String permissionDescription = getDescriptionByPermission(context, permission); - if (TextUtils.isEmpty(permissionDescription)) { - continue; - } - if (descriptionList.contains(permissionDescription)) { - continue; - } - descriptionList.add(permissionDescription); - } - return descriptionList; - } - - /** - * 通过权限获得描述 - */ - @NonNull - public static String getDescriptionByPermission(@NonNull Context context, @NonNull String permission) { - Integer permissionNameStringId = PERMISSION_NAME_MAP.get(permission); - if (permissionNameStringId == null || permissionNameStringId == 0) { - return ""; - } - String permissionName = context.getString(permissionNameStringId); - Integer permissionDescriptionStringId = PERMISSION_DESCRIPTION_MAP.get(permissionNameStringId); - String permissionDescription; - if (permissionDescriptionStringId == null || permissionDescriptionStringId == 0) { - permissionDescription = ""; - } else { - permissionDescription = context.getString(permissionDescriptionStringId); - } - return permissionName + context.getString(R.string.common_permission_colon) + permissionDescription; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/permission/PermissionDescription.java b/app/src/main/java/com/ght/keepalive/permission/PermissionDescription.java deleted file mode 100644 index 3f9166c..0000000 --- a/app/src/main/java/com/ght/keepalive/permission/PermissionDescription.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.ght.keepalive.permission; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.DialogInterface; -import android.content.res.Configuration; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.os.Handler; -import android.os.Looper; -import android.os.SystemClock; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.PopupWindow; -import android.widget.TextView; -import com.ght.keepalive.R; - - -import com.hjq.permissions.OnPermissionDescription; -import com.hjq.permissions.XXPermissions; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -/** - * author : Android 轮子哥 - * github : https://github.com/getActivity/XXPermissions - * time : 2025/05/30 - * desc : 权限请求描述实现 - */ -public final class PermissionDescription implements OnPermissionDescription { - - /** 消息处理 Handler 对象 */ - public static final Handler HANDLER = new Handler(Looper.getMainLooper()); - - /** 权限请求描述弹窗显示类型:Dialog */ - private static final int DESCRIPTION_WINDOW_TYPE_DIALOG = 0; - /** 权限请求描述弹窗显示类型:PopupWindow */ - private static final int DESCRIPTION_WINDOW_TYPE_POPUP = 1; - - /** 权限请求描述弹窗显示类型 */ - private int mDescriptionWindowType = DESCRIPTION_WINDOW_TYPE_DIALOG; - - /** 消息 Token */ - @NonNull - private final Object mHandlerToken = new Object(); - - /** 权限申请说明弹窗 */ - @Nullable - private PopupWindow mPermissionPopupWindow; - - /** 权限申请说明对话框 */ - @Nullable - private Dialog mPermissionDialog; - - @Override - public void askWhetherRequestPermission(@NonNull Activity activity, @NonNull List requestPermissions, - @NonNull Runnable continueRequestRunnable, @NonNull Runnable breakRequestRunnable) { - if (XXPermissions.containsSpecialPermission(requestPermissions)) { - // 如果请求的权限中包含特殊权限,那么就用 Dialog 来展示权限说明弹窗 - mDescriptionWindowType = DESCRIPTION_WINDOW_TYPE_DIALOG; - } else { - int activityOrientation = activity.getResources().getConfiguration().orientation; - // 如果当前 Activity 的屏幕是竖屏的话,就用 PopupWindow 展示权限说明弹窗,否则用 Dialog 来展示权限说明弹窗 - mDescriptionWindowType = activityOrientation == Configuration.ORIENTATION_PORTRAIT ? - DESCRIPTION_WINDOW_TYPE_POPUP : DESCRIPTION_WINDOW_TYPE_DIALOG; - // 如果本次申请的权限中带有后台权限(例如后台定位权限、后台传感器权限等),则改用 Dialog 来展示权限说明弹窗 - if (XXPermissions.containsBackgroundPermission(requestPermissions)) { - mDescriptionWindowType = DESCRIPTION_WINDOW_TYPE_DIALOG; - } - } - - if (mDescriptionWindowType == DESCRIPTION_WINDOW_TYPE_POPUP) { - continueRequestRunnable.run(); - return; - } - - showDialog(activity, activity.getString(R.string.common_permission_description_title), - generatePermissionDescription(activity, requestPermissions), - activity.getString(R.string.common_permission_granted), (dialog, which) -> { - dialog.dismiss(); - continueRequestRunnable.run(); - }, activity.getString(R.string.common_permission_denied), (dialog, which) -> { - dialog.dismiss(); - breakRequestRunnable.run(); - }); - } - - @Override - public void onRequestPermissionStart(@NonNull Activity activity, @NonNull List requestPermissions) { - if (mDescriptionWindowType != DESCRIPTION_WINDOW_TYPE_POPUP) { - return; - } - - Runnable showPopupRunnable = () -> showPopupWindow(activity, generatePermissionDescription(activity, requestPermissions)); - // 这里解释一下为什么要延迟一段时间再显示 PopupWindow,这是因为系统没有开放任何 API 给外层直接获取权限是否永久拒绝 - // 目前只有申请过了权限才能通过 shouldShowRequestPermissionRationale 判断是不是永久拒绝,如果此前没有申请过权限,则无法判断 - // 针对这个问题能想到最佳的解决方案是:先申请权限,如果极短的时间内,权限申请没有结束,则证明权限之前没有被用户勾选了《不再询问》 - // 此时系统的权限弹窗正在显示给用户,这个时候再去显示应用的 PopupWindow 权限说明弹窗给用户看,所以这个 PopupWindow 是在发起权限申请后才显示的 - // 这样做是为了避免 PopupWindow 显示了又马上消失,这样就不会出现 PopupWindow 一闪而过的效果,提升用户的视觉体验 - // 最后补充一点:350 毫秒只是一个经验值,经过测试可覆盖大部分机型,具体可根据实际情况进行调整,这里不做强制要求 - // 相关 Github issue 地址:https://github.com/getActivity/XXPermissions/issues/366 - HANDLER.postAtTime(showPopupRunnable, mHandlerToken, SystemClock.uptimeMillis() + 350); - } - - @Override - public void onRequestPermissionEnd(@NonNull Activity activity, @NonNull List requestPermissions) { - // 移除跟这个 Token 有关但是没有还没有执行的消息 - HANDLER.removeCallbacksAndMessages(mHandlerToken); - // 销毁当前正在显示的弹窗 - dismissPopupWindow(); - dismissDialog(); - } - - /** - * 生成权限描述文案 - */ - private String generatePermissionDescription(@NonNull Activity activity, @NonNull List requestPermissions) { - return PermissionConverter.getDescriptionsByPermissions(activity, requestPermissions); - } - - /** - * 显示 Dialog - * - * @param activity Activity 对象 - * @param dialogTitle 对话框标题 - * @param dialogMessage 对话框消息 - * @param confirmButtonText 对话框确认按钮文本 - * @param confirmListener 对话框确认按钮点击事件 - * @param cancelButtonText 对话框取消按钮文本 - * @param cancelListener 对话框取消按钮点击事件 - */ - private void showDialog(@NonNull Activity activity, @Nullable String dialogTitle, @Nullable String dialogMessage, - @Nullable String confirmButtonText, @Nullable DialogInterface.OnClickListener confirmListener, - @Nullable String cancelButtonText, @Nullable DialogInterface.OnClickListener cancelListener) { - if (mPermissionDialog != null) { - dismissDialog(); - } - if (activity.isFinishing() || activity.isDestroyed()) { - return; - } - // 另外这里需要判断 Activity 的类型来申请权限,这是因为只有 AppCompatActivity 才能调用 Support 包的 AlertDialog 来显示,否则会出现报错 - // java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity - // 为什么不直接用 App 包 AlertDialog 来显示,而是两套规则?因为 App 包 AlertDialog 是系统自带的类,不同 Android 版本展现的样式可能不太一样 - // 如果这个 Android 版本比较低,那么这个对话框的样式就会变得很丑,准确来讲也不能说丑,而是当时系统的 UI 设计就是那样,它只是跟随系统的样式而已 - if (activity instanceof AppCompatActivity) { - mPermissionDialog = new androidx.appcompat.app.AlertDialog.Builder(activity) - .setTitle(dialogTitle) - .setMessage(dialogMessage) - // 对话框一定要设置成不可取消的 - .setCancelable(false) - .setPositiveButton(confirmButtonText, confirmListener) - .setNegativeButton(cancelButtonText, cancelListener) - .create(); - } else { - mPermissionDialog = new AlertDialog.Builder(activity) - .setTitle(dialogTitle) - .setMessage(dialogMessage) - // 对话框一定要设置成不可取消的 - .setCancelable(false) - .setPositiveButton(confirmButtonText, confirmListener) - .setNegativeButton(cancelButtonText, cancelListener) - .create(); - } - mPermissionDialog.show(); - // 将 Activity 和 Dialog 生命周期绑定在一起,避免可能会出现的内存泄漏 - // 当然如果上面创建的 Dialog 已经有做了生命周期管理,则不需要执行下面这行代码 - WindowLifecycleManager.bindDialogLifecycle(activity, mPermissionDialog); - } - - /** - * 销毁 Dialog - */ - private void dismissDialog() { - if (mPermissionDialog == null) { - return; - } - if (!mPermissionDialog.isShowing()) { - return; - } - mPermissionDialog.dismiss(); - mPermissionDialog = null; - } - - /** - * 显示 PopupWindow - * - * @param activity Activity 对象 - * @param content 弹窗显示的内容 - */ - private void showPopupWindow(@NonNull Activity activity, @NonNull String content) { - if (mPermissionPopupWindow != null) { - dismissPopupWindow(); - } - if (activity.isFinishing() || activity.isDestroyed()) { - return; - } - ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView(); - View contentView = LayoutInflater.from(activity) - .inflate(R.layout.permission_description_popup, decorView, false); - mPermissionPopupWindow = new PopupWindow(activity); - mPermissionPopupWindow.setContentView(contentView); - mPermissionPopupWindow.setWidth(WindowManager.LayoutParams.MATCH_PARENT); - mPermissionPopupWindow.setHeight(WindowManager.LayoutParams.WRAP_CONTENT); - mPermissionPopupWindow.setAnimationStyle(android.R.style.Animation_Dialog); - mPermissionPopupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - mPermissionPopupWindow.setTouchable(true); - mPermissionPopupWindow.setOutsideTouchable(true); - TextView messageView = mPermissionPopupWindow.getContentView().findViewById(R.id.tv_permission_description_message); - messageView.setText(content); - // 注意:这里的 PopupWindow 只是示例,没有监听 Activity onDestroy 来处理 PopupWindow 生命周期 - mPermissionPopupWindow.showAtLocation(decorView, Gravity.TOP, 0, 0); - // 将 Activity 和 PopupWindow 生命周期绑定在一起,避免可能会出现的内存泄漏 - // 当然如果上面创建的 PopupWindow 已经有做了生命周期管理,则不需要执行下面这行代码 - WindowLifecycleManager.bindPopupWindowLifecycle(activity, mPermissionPopupWindow); - } - - /** - * 销毁 PopupWindow - */ - private void dismissPopupWindow() { - if (mPermissionPopupWindow == null) { - return; - } - if (!mPermissionPopupWindow.isShowing()) { - return; - } - mPermissionPopupWindow.dismiss(); - mPermissionPopupWindow = null; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/permission/PermissionInterceptor.java b/app/src/main/java/com/ght/keepalive/permission/PermissionInterceptor.java deleted file mode 100644 index 771ffcf..0000000 --- a/app/src/main/java/com/ght/keepalive/permission/PermissionInterceptor.java +++ /dev/null @@ -1,186 +0,0 @@ -package com.ght.keepalive.permission; - -import android.app.Activity; -import android.app.AlertDialog.Builder; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.text.TextUtils; - -import com.ght.keepalive.R; -import com.ght.keepalive.util.ToastUtil; -import com.hjq.permissions.OnPermissionCallback; -import com.hjq.permissions.OnPermissionInterceptor; -import com.hjq.permissions.OnPermissionPageCallback; -import com.hjq.permissions.Permission; -import com.hjq.permissions.XXPermissions; - -import java.util.List; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -/** - * author : Android 轮子哥 - * github : https://github.com/getActivity/XXPermissions - * time : 2021/01/04 - * desc : 权限申请拦截器 - */ -public final class PermissionInterceptor implements OnPermissionInterceptor { - - @Override - public void deniedPermissionRequest(@NonNull Activity activity, @NonNull List requestPermissions, - @NonNull List deniedPermissions, boolean doNotAskAgain, - @Nullable OnPermissionCallback callback) { - if (callback != null) { - callback.onDenied(deniedPermissions, doNotAskAgain); - } - - if (doNotAskAgain) { - if (deniedPermissions.size() == 1 && Permission.ACCESS_MEDIA_LOCATION.equals(deniedPermissions.get(0))) { - return; - } - - showPermissionSettingDialog(activity, requestPermissions, deniedPermissions, callback); - return; - } - - if (deniedPermissions.size() == 1) { - - String deniedPermission = deniedPermissions.get(0); - - String backgroundPermissionOptionLabel = getBackgroundPermissionOptionLabel(activity); - - if (Permission.ACCESS_BACKGROUND_LOCATION.equals(deniedPermission)) { - ToastUtil.Companion.showShort(activity.getString(R.string.common_permission_background_location_fail_hint, backgroundPermissionOptionLabel)); - return; - } - - if (Permission.BODY_SENSORS_BACKGROUND.equals(deniedPermission)) { - ToastUtil.Companion.showShort(activity.getString(R.string.common_permission_background_sensors_fail_hint, backgroundPermissionOptionLabel)); - return; - } - } - - final String message; - String permissionNames = PermissionConverter.getNamesByPermissions(activity, deniedPermissions); - if (!permissionNames.isEmpty()) { - message = activity.getString(R.string.common_permission_fail_assign_hint, permissionNames); - } else { - message = activity.getString(R.string.common_permission_fail_hint); - } - ToastUtil.Companion.showShort(message); - } - - private void showPermissionSettingDialog(Activity activity, List requestPermissions, - List deniedPermissions, OnPermissionCallback callback) { - if (activity == null || activity.isFinishing() || activity.isDestroyed()) { - return; - } - - String message = null; - - String permissionNames = PermissionConverter.getNamesByPermissions(activity, deniedPermissions); - if (!permissionNames.isEmpty()) { - if (deniedPermissions.size() == 1) { - String deniedPermission = deniedPermissions.get(0); - - if (Permission.ACCESS_BACKGROUND_LOCATION.equals(deniedPermission)) { - message = activity.getString(R.string.common_permission_manual_assign_fail_background_location_hint, getBackgroundPermissionOptionLabel(activity)); - } else if (Permission.BODY_SENSORS_BACKGROUND.equals(deniedPermission)) { - message = activity.getString(R.string.common_permission_manual_assign_fail_background_sensors_hint, getBackgroundPermissionOptionLabel(activity)); - } - } - if (TextUtils.isEmpty(message)) { - message = activity.getString(R.string.common_permission_manual_assign_fail_hint, permissionNames); - } - } else { - message = activity.getString(R.string.common_permission_manual_fail_hint); - } - - showDialog(activity, activity.getString(R.string.common_permission_alert), message, true, activity.getString(R.string.common_permission_goto_setting_page), (dialog, which) -> { - dialog.dismiss(); - XXPermissions.startPermissionActivity(activity, deniedPermissions, new OnPermissionPageCallback() { - - @Override - public void onGranted() { - if (callback == null) { - return; - } - callback.onGranted(requestPermissions, true); - } - - @Override - public void onDenied() { - showPermissionSettingDialog(activity, requestPermissions, - XXPermissions.getDeniedPermissions(activity, requestPermissions), callback); - } - }); - }); - } - - /** - * 获取后台权限的《始终允许》选项的文案 - */ - @NonNull - private String getBackgroundPermissionOptionLabel(Context context) { - String backgroundPermissionOptionLabel = ""; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - backgroundPermissionOptionLabel = String.valueOf(context.getPackageManager().getBackgroundPermissionOptionLabel()); - } - if (TextUtils.isEmpty(backgroundPermissionOptionLabel)) { - backgroundPermissionOptionLabel = context.getString(R.string.common_permission_background_default_option_label); - } - return backgroundPermissionOptionLabel; - } - - private void showDialog(@NonNull Activity activity, @Nullable String dialogTitle, @Nullable String dialogMessage, boolean dialogCancelable, - @Nullable String confirmButtonText, @Nullable DialogInterface.OnClickListener confirmListener) { - showDialog(activity, dialogTitle, dialogMessage, dialogCancelable, confirmButtonText, confirmListener, null, null); - } - - /** - * 显示对话框 - * - * @param activity Activity 对象 - * @param dialogTitle 对话框标题 - * @param dialogMessage 对话框消息 - * @param dialogCancelable 对话框是否可取消 - * @param confirmButtonText 对话框确认按钮文本 - * @param confirmListener 对话框确认按钮点击事件 - * @param cancelButtonText 对话框取消按钮文本 - * @param cancelListener 对话框取消按钮点击事件 - */ - private void showDialog(@NonNull Activity activity, @Nullable String dialogTitle, @Nullable String dialogMessage, boolean dialogCancelable, - @Nullable String confirmButtonText, @Nullable DialogInterface.OnClickListener confirmListener, - @Nullable String cancelButtonText, @Nullable DialogInterface.OnClickListener cancelListener) { - // 另外这里需要判断 Activity 的类型来申请权限,这是因为只有 AppCompatActivity 才能调用 Support 包的 AlertDialog 来显示,否则会出现报错 - // java.lang.IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity - // 为什么不直接用 App 包 AlertDialog 来显示,而是两套规则?因为 App 包 AlertDialog 是系统自带的类,不同 Android 版本展现的样式可能不太一样 - // 如果这个 Android 版本比较低,那么这个对话框的样式就会变得很丑,准确来讲也不能说丑,而是当时系统的 UI 设计就是那样,它只是跟随系统的样式而已 - Dialog dialog; - if (activity instanceof AppCompatActivity) { - dialog = new androidx.appcompat.app.AlertDialog.Builder(activity) - .setTitle(dialogTitle) - .setMessage(dialogMessage) - .setCancelable(dialogCancelable) - .setPositiveButton(confirmButtonText, confirmListener) - .setNegativeButton(cancelButtonText, cancelListener) - .create(); - } else { - dialog = new Builder(activity) - .setTitle(dialogTitle) - .setMessage(dialogMessage) - .setCancelable(dialogCancelable) - .setPositiveButton(confirmButtonText, confirmListener) - .setNegativeButton(cancelButtonText, cancelListener) - .create(); - } - dialog.show(); - // 将 Activity 和 Dialog 生命周期绑定在一起,避免可能会出现的内存泄漏 - // 当然如果上面创建的 Dialog 已经有做了生命周期管理,则不需要执行下面这行代码 - WindowLifecycleManager.bindDialogLifecycle(activity, dialog); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/permission/WindowLifecycleManager.java b/app/src/main/java/com/ght/keepalive/permission/WindowLifecycleManager.java deleted file mode 100644 index 0eaae81..0000000 --- a/app/src/main/java/com/ght/keepalive/permission/WindowLifecycleManager.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.ght.keepalive.permission; - -import android.app.Activity; -import android.app.Application.ActivityLifecycleCallbacks; -import android.app.Dialog; -import android.os.Build; -import android.os.Bundle; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * author : Android 轮子哥 - * github : https://github.com/getActivity/XXPermissions - * time : 2025/05/30 - * desc : 窗口生命周期管理 - */ -public final class WindowLifecycleManager { - - /** - * 将 Activity 和 Dialog 的生命周期绑定在一起 - */ - public static void bindDialogLifecycle(@NonNull Activity activity, @NonNull Dialog dialog) { - WindowLifecycleCallbacks windowLifecycleCallbacks = new WindowLifecycleCallbacks(activity) { - - @Override - public void onWindowDismiss() { - if (!dialog.isShowing()) { - return; - } - dialog.dismiss(); - } - }; - registerWindowLifecycleCallbacks(activity, windowLifecycleCallbacks); - } - - /** - * 将 Activity 和 PopupWindow 的生命周期绑定在一起 - */ - public static void bindPopupWindowLifecycle(@NonNull Activity activity, @NonNull PopupWindow popupWindow) { - WindowLifecycleCallbacks windowLifecycleCallbacks = new WindowLifecycleCallbacks(activity) { - - @Override - public void onWindowDismiss() { - if (!popupWindow.isShowing()) { - return; - } - popupWindow.dismiss(); - } - }; - registerWindowLifecycleCallbacks(activity, windowLifecycleCallbacks); - } - - /** - * 注册窗口回调 - */ - private static void registerWindowLifecycleCallbacks(@NonNull Activity activity, @NonNull WindowLifecycleCallbacks callbacks) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - activity.registerActivityLifecycleCallbacks(callbacks); - } else { - activity.getApplication().registerActivityLifecycleCallbacks(callbacks); - } - } - - /** - * 反注册窗口回调 - */ - private static void unregisterWindowLifecycleCallbacks(@NonNull Activity activity, @NonNull WindowLifecycleCallbacks callbacks) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - activity.unregisterActivityLifecycleCallbacks(callbacks); - } else { - activity.getApplication().unregisterActivityLifecycleCallbacks(callbacks); - } - } - - /** - * 窗口生命周期回调 - */ - private abstract static class WindowLifecycleCallbacks implements ActivityLifecycleCallbacks { - - @Nullable - private Activity mActivity; - - private WindowLifecycleCallbacks(@NonNull Activity activity) { - mActivity = activity; - } - - public abstract void onWindowDismiss(); - - @Override - public final void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {} - - @Override - public final void onActivityStarted(@NonNull Activity activity) {} - - @Override - public final void onActivityResumed(@NonNull Activity activity) {} - - @Override - public final void onActivityPaused(@NonNull Activity activity) {} - - @Override - public final void onActivityStopped(@NonNull Activity activity) {} - - @Override - public final void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} - - @Override - public final void onActivityDestroyed(@NonNull Activity activity) { - if (activity != mActivity) { - return; - } - // 释放 Activity 对象 - mActivity = null; - // 反注册窗口监听 - unregisterWindowLifecycleCallbacks(activity, this); - // 通知外层销毁窗口 - onWindowDismiss(); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/util/OemPermissionHelper.kt b/app/src/main/java/com/ght/keepalive/util/OemPermissionHelper.kt deleted file mode 100644 index 86589f2..0000000 --- a/app/src/main/java/com/ght/keepalive/util/OemPermissionHelper.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.ght.keepalive.util - -import android.app.AppOpsManager -import android.content.Context -import android.net.ConnectivityManager -import android.net.Uri -import android.os.Build -import android.os.Process -import timber.log.Timber - -/** - * 针对国内特定厂商(OEM, 如小米、VIVO)的非标准权限检测工具类。 - * 主要用于检查“后台弹出界面”和“锁屏显示”等特殊权限。 - * 日志记录已适配 Timber。 - */ -object OemPermissionHelper { - // 使用 Timber 后,不再需要手动定义 TAG - - // 小米权限对应的 op code - private const val OP_XIAOMI_BACKGROUND_POPUP = 10021 // 后台弹出界面 - private const val OP_XIAOMI_SHOW_WHEN_LOCKED = 10020 // 锁屏显示 - - // VIVO 权限查询相关的 Uri - private val VIVO_URI_BG_START_ACTIVITY = Uri.parse("content://com.vivo.permissionmanager.provider.permission/start_bg_activity") - private val VIVO_URI_LOCK_SCREEN_ACTION = Uri.parse("content://com.vivo.permissionmanager.provider.permission/control_locked_screen_action") - - // VIVO 权限返回值 - const val VIVO_PERMISSION_ALLOWED = 0 // 允许 - const val VIVO_PERMISSION_DENIED = 1 // 拒绝 - - - - /** - * 检测小米的“后台弹出界面”权限是否开启。 - * 为保证Java代码兼容性,添加了 @JvmStatic。如果项目为纯Kotlin,可以移除。 - * - * @param context 上下文 - * @return `true` 表示权限已开启, `false` 表示权限未开启或检测失败 - */ - @JvmStatic - fun isXiaomiBackgroundPopupAllowed(context: Context): Boolean { - return checkXiaomiOp(context, OP_XIAOMI_BACKGROUND_POPUP) - } - - /** - * 检测小米的“锁屏显示”权限是否开启。 - * 为保证Java代码兼容性,添加了 @JvmStatic。如果项目为纯Kotlin,可以移除。 - * - * @param context 上下文 - * @return `true` 表示权限已开启, `false` 表示权限未开启或检测失败 - */ - @JvmStatic - fun canXiaomiShowWhenLocked(context: Context): Boolean { - return checkXiaomiOp(context, OP_XIAOMI_SHOW_WHEN_LOCKED) - } - - /** - * 检测 VIVO 的“后台弹出界面”权限状态。 - * 为保证Java代码兼容性,添加了 @JvmStatic。如果项目为纯Kotlin,可以移除。 - * - * @param context 上下文 - * @return [VIVO_PERMISSION_ALLOWED] (0) 表示开启, [VIVO_PERMISSION_DENIED] (1) 表示关闭或查询失败 - */ - @JvmStatic - fun getVivoBgStartPermissionStatus(context: Context): Int { - return queryVivoPermission(context, VIVO_URI_BG_START_ACTIVITY) - } - - /** - * 检测 VIVO 的“锁屏显示”权限状态。 - * 为保证Java代码兼容性,添加了 @JvmStatic。如果项目为纯Kotlin,可以移除。 - * - * @param context 上下文 - * @return [VIVO_PERMISSION_ALLOWED] (0) 表示开启, [VIVO_PERMISSION_DENIED] (1) 表示关闭或查询失败 - */ - @JvmStatic - fun getVivoLockStatus(context: Context): Int { - return queryVivoPermission(context, VIVO_URI_LOCK_SCREEN_ACTION) - } - - /** - * 统一查询小米 AppOps 权限的私有方法 - */ - private fun checkXiaomiOp(context: Context, op: Int): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { - return false - } - val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager - if (ops == null) { - // 改动点 1: 使用 Timber 记录错误 - Timber.e("AppOpsManager is null, cannot check permission.") - return false - } - - try { - val method = ops.javaClass.getMethod( - "checkOpNoThrow", - Int::class.java, - Int::class.java, - String::class.java - ) - val result = method.invoke( - ops, - op, - Process.myUid(), - context.packageName - ) as? Int - - return result == AppOpsManager.MODE_ALLOWED - } catch (e: Exception) { - // 改动点 2: 使用 Timber 记录异常和上下文信息 - Timber.e(e, "checkOpNoThrow for op %d not supported", op) - } - return false - } - - /** - * 统一查询 VIVO 权限的私有方法 - */ - private fun queryVivoPermission(context: Context, uri: Uri): Int { - val packageName = context.packageName - val selection = "pkgname = ?" - val selectionArgs = arrayOf(packageName) - - try { - context.contentResolver.query(uri, null, selection, selectionArgs, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val index = cursor.getColumnIndex("currentstate") - if (index != -1) { - return cursor.getInt(index) - } - } - } - } catch (e: Throwable) { - // 改动点 3: 使用 Timber 记录异常 - Timber.e(e, "Error querying vivo permission for uri: %s", uri) - } - return VIVO_PERMISSION_DENIED - } - - /** - * 检查本应用是否可以在后台使用数据(对应安卓系统的“流量节省程序”功能)。 - * - * @param context a Context - * @return true 如果后台数据未受限制, false 如果被限制。 - */ - @JvmStatic - fun isBackgroundDataAllowed(context: Context): Boolean { - val connMgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - if (connMgr == null) { - Timber.w("ConnectivityManager is null. Assuming background data is allowed.") - return true // 获取服务失败,返回一个安全的默认值 - } - - return when (connMgr.restrictBackgroundStatus) { - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> false // 被限制 - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED, // 在白名单中 - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> true // 功能未开启 - else -> true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/util/StatusCheckerRegistry.kt b/app/src/main/java/com/ght/keepalive/util/StatusCheckerRegistry.kt deleted file mode 100644 index e0d1958..0000000 --- a/app/src/main/java/com/ght/keepalive/util/StatusCheckerRegistry.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.ght.keepalive.util - -import android.content.Context -import android.net.ConnectivityManager -import android.os.Build -import com.ght.keepalive.enumid.SettingId -import com.hjq.permissions.Permission -import com.hjq.permissions.XXPermissions -import timber.log.Timber - -object StatusCheckerRegistry { - - private val checkers: Map Boolean> = mapOf( - // Key 现在是类型安全的枚举,而不是易错的字符串 - SettingId.BATTERY_OPTIMIZATION to { context -> - val check = XXPermissions.isGrantedPermissions( - context, - Permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - ) - Timber.d("checkers,BATTERY_OPTIMIZATION:$check") - check - },SettingId.ALERT_WINDOW to { context -> - val check = XXPermissions.isGrantedPermissions( - context, - Permission.SYSTEM_ALERT_WINDOW - ) - Timber.d("checkers,SYSTEM_ALERT_WINDOW:$check") - check - } - , SettingId.BACKGROUND_DATA to { context -> - isBackgroundDataAllowed(context) - } - , SettingId.XIAOMI_BACKGROUND_POPUP to { context -> - OemPermissionHelper.isXiaomiBackgroundPopupAllowed(context) - } - , SettingId.XIAOMI_LOCK_POPUP to { context -> - OemPermissionHelper.canXiaomiShowWhenLocked(context) - } - ) - - /** - * 公开的检查方法,参数类型也变为 SettingId - */ - fun check(id: SettingId, context: Context): Boolean { - val checker = checkers[id] - return checker?.invoke(context) ?: false - } - - private fun isBackgroundDataAllowed(context: Context): Boolean { - val connMgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - // 检查安卓版本是否支持 getRestrictBackgroundStatus - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val status = connMgr.restrictBackgroundStatus - when (status) { - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> return false - - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> return true - - ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> return true - } - } - - // 对于安卓 7.0 以下的旧版本,我们可以检查一个已废弃的设置。 - // 在大多数情况下,我们可以认为它是允许的,因为旧系统缺少精细化控制。 - // 注意: getBackgroundDataSetting() 在 API 24 中已废弃,并且总是返回 true。 - // noinspection deprecation - return connMgr.backgroundDataSetting - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/util/SystemUtils.kt b/app/src/main/java/com/ght/keepalive/util/SystemUtils.kt deleted file mode 100644 index ede8a2f..0000000 --- a/app/src/main/java/com/ght/keepalive/util/SystemUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.ght.keepalive.util - - -import java.io.BufferedReader -import java.io.InputStreamReader - -object SystemUtils { - fun getSystemProperty(key: String): String? { - try { - val clz = Class.forName("android.os.SystemProperties") - val getMethod = clz.getMethod("get", String::class.java, String::class.java) - return getMethod.invoke(clz, key, null) as? String - } catch (e: Exception) { - return getSystemPropertyByShell(key) - } - } - - private fun getSystemPropertyByShell(key: String): String? { - try { - val process = Runtime.getRuntime().exec("getprop $key") - BufferedReader(InputStreamReader(process.inputStream)).use { reader -> - return reader.readLine() - } - } catch (e: Exception) { - e.printStackTrace() - return null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/keepalive/util/ToastUtil.kt b/app/src/main/java/com/ght/keepalive/util/ToastUtil.kt deleted file mode 100644 index 3304765..0000000 --- a/app/src/main/java/com/ght/keepalive/util/ToastUtil.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.ght.keepalive.util - -import android.widget.Toast -import com.ght.keepalive.MyApplication -import timber.log.Timber - -class ToastUtil { - companion object { - fun showShort(msg: String) { - Timber.i(msg) - Toast.makeText(MyApplication.instance, msg, Toast.LENGTH_SHORT).show() - } - fun showShortError(msg: String) { - Timber.e(msg) - Toast.makeText(MyApplication.instance, msg, Toast.LENGTH_SHORT).show() - } - - fun showLong(msg: String) { - Timber.i(msg) - Toast.makeText(MyApplication.instance, msg, Toast.LENGTH_LONG).show() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ght/repository/SettingsRepository.kt b/app/src/main/java/com/ght/repository/SettingsRepository.kt deleted file mode 100644 index 2be6c0b..0000000 --- a/app/src/main/java/com/ght/repository/SettingsRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.ght.repository - -import android.content.Context -import com.ght.keepalive.model.ManufacturerConfig -import kotlinx.serialization.json.Json -import java.io.BufferedReader -import java.io.FileNotFoundException - -class SettingsRepository(private val context: Context) { - - private val json = Json { ignoreUnknownKeys = true; isLenient = true } - - fun loadManufacturerConfig(manufacturer: String): ManufacturerConfig? { - return try { - // 尝试加载厂商专属配置文件 - val fileName = "$manufacturer.json" - context.assets.open(fileName).bufferedReader().use(BufferedReader::readText) - .let { json.decodeFromString(ManufacturerConfig.serializer(), it) } - } catch (e: FileNotFoundException) { - // 如果找不到,则加载默认配置文件 - try { - context.assets.open("default.json").bufferedReader().use(BufferedReader::readText) - .let { json.decodeFromString(ManufacturerConfig.serializer(), it) } - } catch (e: Exception) { - e.printStackTrace() - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable/permission_description_popup_bg.xml b/app/src/main/res/drawable/permission_description_popup_bg.xml deleted file mode 100644 index b33fb6a..0000000 --- a/app/src/main/res/drawable/permission_description_popup_bg.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/permission_description_popup.xml b/app/src/main/res/layout/permission_description_popup.xml deleted file mode 100644 index 6a62ae9..0000000 --- a/app/src/main/res/layout/permission_description_popup.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d80497d..ee3dccd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,71 +1,5 @@ - KeepAlive - - 权限说明 - 授予 - 取消 - - 授权提醒 - 授权失败,请正确授予权限 - 授权失败,请正确授予%s - 获取权限失败,请手动授予权限 - 获取权限失败,请手动授予%s - 获取后台定位权限失败,请在定位权限中选择%s - 获取后台传感器权限失败,请在传感器权限中选择%s - 获取后台定位权限失败,\n请选择%s选项 - 获取后台传感器权限失败,\n请选择%s选项 - 始终允许 - 获取媒体位置权限失败\n请清除应用数据后重试 - 前往授权 - - 权限 - - - - 日历权限 - 相机权限 - 通讯录权限 - 定位权限 - 后台定位权限 - 附近设备权限 - 麦克风权限 - 电话权限 - 通话记录权限 - 身体传感器权限 - 后台身体传感器权限 - 健身运动权限 - 身体活动权限 - 读取媒体文件位置权限 - 短信权限 - 存储权限 - 发送通知权限 - 照片和视频权限 - 音乐和音频权限 - 读取应用列表权限 - - 所有文件访问权限 - 安装应用权限 - 悬浮窗权限 - 修改系统设置权限 - 通知权限 - 通知栏监听权限 - 查看使用情况权限 - 查看闹钟提醒权限 - 勿扰权限 - 忽略电池优化权限 - 画中画权限 - \tVPN\t权限 - - 用于 xx 业务 - - 获取%s成功 - 检测到你刚刚从权限设置界面返回回来 - - 当前版本不是 Android 12 及以上,旧版本的需要定位权限才能进行扫描蓝牙 - 当前版本不是 Android 13 及以上,旧版本的需要定位权限才能进行扫描 WIFI - 当前版本不是 Android 10 及以上,旧版本的需要读取存储权限才能获取媒体位置权限 - 当前版本不是 Android 13 及以上,会自动变更为旧版的请求方式 - 当前版本不是 Android 11 及以上,会自动变更为旧版的请求方式 - 当前版本不是 Android 13 及以上,会自动变更为旧版的请求方式 - - \ No newline at end of file + LegitKeepAlive Sample + 当前系统不支持该设置页面 + 无法打开设置页面 + diff --git a/app/src/test/java/com/ght/keepalive/ExampleUnitTest.kt b/app/src/test/java/com/ght/keepalive/ExampleUnitTest.kt deleted file mode 100644 index c93fd05..0000000 --- a/app/src/test/java/com/ght/keepalive/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ght.keepalive - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 922f551..55f0670 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false -} \ No newline at end of file + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/docs/oem-support-matrix.md b/docs/oem-support-matrix.md index 6a067c6..ff4faf0 100644 --- a/docs/oem-support-matrix.md +++ b/docs/oem-support-matrix.md @@ -38,7 +38,7 @@ The following vendors are strong candidates for future support: When adding or changing support: -1. update the corresponding JSON under `app/src/main/assets/` +1. update the corresponding JSON under `keepalive-core/src/main/assets/legitkeepalive/` 2. update any status-check helper logic if needed 3. validate on real hardware where possible 4. update this matrix with the latest status and notes diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..0fd1f10 --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,26 @@ +# LegitKeepAlive v0.1.0 + +The first formal release turns LegitKeepAlive into a reusable Android Library with a stable initial API and a separate sample application. + +## Highlights + +- Add the `keepalive-core` Android Library. +- Add `LegitKeepAlive.create(context)` as the public entry point. +- Resolve generic, Xiaomi MIUI 12, and Honor MagicOS 9.x recommendations from bundled rules. +- Safely check supported settings and open OEM settings pages. +- Validate bundled configurations through unit and Robolectric tests. +- Publish source and binary artifacts with checksums. + +## Artifacts + +- `legitkeepalive-0.1.0.aar`: reusable Android Library. +- `legitkeepalive-sample-0.1.0.apk`: sample application for physical-device testing. +- `SHA256SUMS.txt`: SHA-256 checksums for release artifacts. + +## Known Limitations + +- OEM settings components may change across device models and ROM updates. +- Some recommendations cannot be checked programmatically and return an unknown status. +- Real-device validation currently focuses on generic Android, Xiaomi MIUI 12, and Honor MagicOS 9.x rules. + +See the [README](../../README.md) for installation and usage. diff --git a/gradle.properties b/gradle.properties index f59eb09..bcedc03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,3 +23,5 @@ kotlin.code.style=official android.nonTransitiveRClass=true android.enableJetifier=true + +VERSION_NAME=0.1.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbf22f0..56887f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,54 +1,27 @@ [versions] -# AGP 和 Kotlin 版本暂时保留,但请注意它们与旧库组合的潜在风险 agp = "8.6.1" -kotlin = "1.9.0" - -# --- AndroidX 和 Material 库降级 --- -# 目标:尝试兼容 compileSdk = 30 - -# androidx.core:core-ktx (原 1.13.1, 需要 34) -> 尝试 1.3.2 (兼容性较好) 或 1.5.0 +kotlin = "2.0.20" +kotlinxSerializationJson = "1.7.3" coreKtx = "1.3.2" - -# androidx.appcompat:appcompat (原 1.7.0, 需要 34) -> 尝试 1.3.1 appcompat = "1.3.1" - -# com.google.android.material:material (原 1.12.0) -> 需要配合appcompat降级,尝试 1.4.0 -# Material 1.4.0 发布时间与 appcompat 1.3.1 相近 material = "1.4.0" - -# androidx.activity:activity (原 1.9.3, 需要 34) -> 尝试 1.2.0 activity = "1.2.0" - -# androidx.constraintlayout:constraintlayout (原 2.2.1, 需要 34) -> 尝试 2.0.4 constraintlayout = "2.0.4" -# --- END AndroidX 和 Material 库降级 --- - -coroutines = "1.7.3" # 适用于 Kotlin 1.9.0 - -lifecycle = "2.3.1" - - - -# 测试库版本暂时保留,如果遇到测试编译问题可能也需要调整 junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" +robolectric = "4.14.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } -androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } -androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } -androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } - +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..856c470 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,6 @@ +jdk: + - openjdk17 +before_install: + - ./gradlew clean +install: + - ./gradlew :keepalive-core:publishReleasePublicationToMavenLocal diff --git a/keepalive-core/build.gradle.kts b/keepalive-core/build.gradle.kts new file mode 100644 index 0000000..91adb8e --- /dev/null +++ b/keepalive-core/build.gradle.kts @@ -0,0 +1,85 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + `maven-publish` +} + +val libraryVersion = providers.gradleProperty("VERSION_NAME").get() + +group = "io.github.ityulong" +version = libraryVersion + +android { + namespace = "io.github.ityulong.legitkeepalive" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) + testImplementation(libs.robolectric) +} + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + groupId = "io.github.ityulong" + artifactId = "legitkeepalive" + version = libraryVersion + + pom { + name.set("LegitKeepAlive") + description.set( + "Android toolkit for policy-compliant background reliability on fragmented OEM ROMs." + ) + url.set("https://github.com/ityulong/LegitKeepAlive") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("ityulong") + name.set("CHEN YULONG") + url.set("https://github.com/ityulong") + } + } + scm { + url.set("https://github.com/ityulong/LegitKeepAlive") + connection.set("scm:git:git://github.com/ityulong/LegitKeepAlive.git") + developerConnection.set("scm:git:ssh://github.com/ityulong/LegitKeepAlive.git") + } + } + } + } + } +} diff --git a/keepalive-core/consumer-rules.pro b/keepalive-core/consumer-rules.pro new file mode 100644 index 0000000..345c894 --- /dev/null +++ b/keepalive-core/consumer-rules.pro @@ -0,0 +1 @@ +# LegitKeepAlive does not require consumer ProGuard rules. diff --git a/keepalive-core/src/main/AndroidManifest.xml b/keepalive-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7425a54 --- /dev/null +++ b/keepalive-core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/assets/default.json b/keepalive-core/src/main/assets/legitkeepalive/default.json similarity index 100% rename from app/src/main/assets/default.json rename to keepalive-core/src/main/assets/legitkeepalive/default.json diff --git a/app/src/main/assets/honor.json b/keepalive-core/src/main/assets/legitkeepalive/honor.json similarity index 100% rename from app/src/main/assets/honor.json rename to keepalive-core/src/main/assets/legitkeepalive/honor.json diff --git a/app/src/main/assets/xiaomi.json b/keepalive-core/src/main/assets/legitkeepalive/xiaomi.json similarity index 100% rename from app/src/main/assets/xiaomi.json rename to keepalive-core/src/main/assets/legitkeepalive/xiaomi.json diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/KeepAliveRecommendation.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/KeepAliveRecommendation.kt new file mode 100644 index 0000000..e35ac2b --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/KeepAliveRecommendation.kt @@ -0,0 +1,26 @@ +package io.github.ityulong.legitkeepalive + +/** + * A policy-compliant background reliability setting recommended for the + * current device and OEM ROM. + */ +class KeepAliveRecommendation private constructor( + val id: String, + val title: String, + val description: String, + val isCheckable: Boolean +) { + + internal companion object { + + @JvmSynthetic + fun create( + id: String, + title: String, + description: String, + isCheckable: Boolean + ): KeepAliveRecommendation { + return KeepAliveRecommendation(id, title, description, isCheckable) + } + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/LegitKeepAlive.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/LegitKeepAlive.kt new file mode 100644 index 0000000..3f73053 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/LegitKeepAlive.kt @@ -0,0 +1,98 @@ +package io.github.ityulong.legitkeepalive + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build +import io.github.ityulong.legitkeepalive.internal.RuleResolver +import io.github.ityulong.legitkeepalive.internal.SettingsIntentFactory +import io.github.ityulong.legitkeepalive.internal.SettingsRepository +import io.github.ityulong.legitkeepalive.internal.StatusChecker +import io.github.ityulong.legitkeepalive.internal.SystemPropertyReader +import java.util.Locale + +/** + * Public entry point for OEM-specific background reliability guidance. + */ +class LegitKeepAlive private constructor(context: Context) { + + private val appContext = context.applicationContext + private val repository = SettingsRepository(appContext) + private val rules by lazy { + val manufacturer = Build.MANUFACTURER.lowercase(Locale.ROOT) + val config = repository.load(manufacturer) + val romVersion = config.osVersionProperty?.let(SystemPropertyReader::get) + RuleResolver.resolve(config, romVersion) + } + + /** + * Returns the settings recommendations that best match the current OEM ROM. + */ + fun recommendations(): List { + return rules.map { rule -> + KeepAliveRecommendation.create( + id = rule.id, + title = rule.title, + description = rule.description, + isCheckable = rule.isCheckable + ) + } + } + + /** + * Returns true or false when the recommendation can be checked, otherwise + * null when Android or the OEM does not expose a reliable status API. + */ + fun isEnabled(recommendation: KeepAliveRecommendation): Boolean? { + if (!recommendation.isCheckable) return null + return StatusChecker.check(recommendation.id, appContext) + } + + /** + * Creates the Android settings intent without starting it. + */ + fun createSettingsIntent(recommendation: KeepAliveRecommendation): Intent? { + val intentSpec = rules.firstOrNull { it.id == recommendation.id }?.intent ?: return null + return SettingsIntentFactory.create(appContext.packageName, intentSpec) + ?.apply { + putExtra("package_label", appLabel()) + } + } + + /** + * Opens the recommendation's settings page and returns a non-throwing + * result that consumers can present to users. + */ + fun openSettings( + context: Context, + recommendation: KeepAliveRecommendation + ): OpenSettingsResult { + val intent = createSettingsIntent(recommendation) ?: return OpenSettingsResult.UNSUPPORTED + if (context !is Activity) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + return try { + context.startActivity(intent) + OpenSettingsResult.OPENED + } catch (_: ActivityNotFoundException) { + OpenSettingsResult.UNSUPPORTED + } catch (_: SecurityException) { + OpenSettingsResult.FAILED + } catch (_: RuntimeException) { + OpenSettingsResult.FAILED + } + } + + private fun appLabel(): String { + return runCatching { + appContext.applicationInfo.loadLabel(appContext.packageManager).toString() + }.getOrDefault(appContext.packageName) + } + + companion object { + @JvmStatic + fun create(context: Context): LegitKeepAlive = LegitKeepAlive(context) + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/OpenSettingsResult.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/OpenSettingsResult.kt new file mode 100644 index 0000000..d9fcbd1 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/OpenSettingsResult.kt @@ -0,0 +1,10 @@ +package io.github.ityulong.legitkeepalive + +/** + * Result of asking Android to open a recommended system settings page. + */ +enum class OpenSettingsResult { + OPENED, + UNSUPPORTED, + FAILED +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/ConfigModels.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/ConfigModels.kt new file mode 100644 index 0000000..d81ad43 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/ConfigModels.kt @@ -0,0 +1,43 @@ +package io.github.ityulong.legitkeepalive.internal + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ManufacturerConfig( + val comment: String, + @SerialName("os_version_property") + val osVersionProperty: String? = null, + @SerialName("default_settings") + val defaultSettings: List, + @SerialName("version_specifics") + val versionSpecifics: List = emptyList() +) + +@Serializable +internal data class VersionSpecific( + @SerialName("version_matches_regex") + val versionMatchesRegex: String, + val comment: String, + val settings: List +) + +@Serializable +internal data class SettingRule( + val id: String, + val title: String, + val description: String, + @SerialName("is_checkable") + val isCheckable: Boolean = false, + val intent: IntentSpec +) + +@Serializable +internal data class IntentSpec( + val action: String? = null, + val permission: String? = null, + val component: String? = null +) { + val isEmpty: Boolean + get() = action.isNullOrBlank() && permission.isNullOrBlank() && component.isNullOrBlank() +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/RuleResolver.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/RuleResolver.kt new file mode 100644 index 0000000..e5dd478 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/RuleResolver.kt @@ -0,0 +1,55 @@ +package io.github.ityulong.legitkeepalive.internal + +internal object RuleResolver { + + fun resolve(config: ManufacturerConfig, romVersion: String?): List { + val selected = config.versionSpecifics + .firstOrNull { version -> + !romVersion.isNullOrBlank() && Regex(version.versionMatchesRegex).matches(romVersion) + } + ?.settings + ?: config.defaultSettings + + return validate(selected) + } + + fun validate(config: ManufacturerConfig): ManufacturerConfig { + validate(config.defaultSettings) + config.versionSpecifics.forEach { version -> + require(version.versionMatchesRegex.isNotBlank()) { + "ROM version match expression must not be blank." + } + runCatching { Regex(version.versionMatchesRegex) } + .getOrElse { cause -> + throw IllegalArgumentException( + "Invalid ROM version match expression: ${version.versionMatchesRegex}", + cause + ) + } + validate(version.settings) + } + return config + } + + fun validate(settings: List): List { + require(settings.isNotEmpty()) { "At least one keep-alive setting is required." } + + val duplicateIds = settings.groupingBy(SettingRule::id) + .eachCount() + .filterValues { count -> count > 1 } + .keys + require(duplicateIds.isEmpty()) { + "Duplicate keep-alive setting IDs: ${duplicateIds.joinToString()}" + } + + settings.forEach { setting -> + require(setting.id.isNotBlank()) { "Keep-alive setting ID must not be blank." } + require(setting.title.isNotBlank()) { "Keep-alive setting title must not be blank." } + require(!setting.intent.isEmpty) { + "Keep-alive setting '${setting.id}' must define a navigation instruction." + } + } + + return settings + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactory.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactory.kt new file mode 100644 index 0000000..f7a04de --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactory.kt @@ -0,0 +1,51 @@ +package io.github.ityulong.legitkeepalive.internal + +import android.Manifest +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +internal object SettingsIntentFactory { + + fun create(packageName: String, spec: IntentSpec): Intent? { + if (spec.isEmpty) return null + + val packageUri = Uri.fromParts("package", packageName, null) + val permissionIntent = when (spec.permission) { + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS -> + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri) + + Manifest.permission.SYSTEM_ALERT_WINDOW -> + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri) + + null -> null + else -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri) + } + + val intent = permissionIntent ?: configuredIntent(spec, packageUri) ?: return null + + intent.putExtra("package_name", packageName) + intent.putExtra("extra_pkgname", packageName) + return intent + } + + private fun configuredIntent(spec: IntentSpec, packageUri: Uri): Intent? { + val intent = Intent() + var hasInstruction = false + + spec.action?.takeIf(String::isNotBlank)?.let { + intent.action = it + hasInstruction = true + } + + spec.component?.takeIf(String::isNotBlank)?.let { flattenedName -> + val component = ComponentName.unflattenFromString(flattenedName) + ?: return if (hasInstruction) intent.apply { data = packageUri } else null + intent.component = component + hasInstruction = true + } + + return intent.takeIf { hasInstruction }?.apply { data = packageUri } + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsRepository.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsRepository.kt new file mode 100644 index 0000000..be04d88 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SettingsRepository.kt @@ -0,0 +1,41 @@ +package io.github.ityulong.legitkeepalive.internal + +import android.content.Context +import kotlinx.serialization.json.Json +import java.io.FileNotFoundException +import java.util.Locale + +internal class SettingsRepository( + private val context: Context, + private val json: Json = Json { + ignoreUnknownKeys = true + isLenient = true + } +) { + + fun load(manufacturer: String): ManufacturerConfig { + val safeManufacturer = manufacturer + .lowercase(Locale.ROOT) + .replace(Regex("[^a-z0-9_-]"), "") + val manufacturerConfig = safeManufacturer + .takeIf(String::isNotBlank) + ?.let { loadAsset("$ASSET_DIRECTORY/$it.json") } + + return manufacturerConfig + ?: loadAsset("$ASSET_DIRECTORY/default.json") + ?: error("LegitKeepAlive default configuration is missing.") + } + + private fun loadAsset(path: String): ManufacturerConfig? { + return try { + val content = context.assets.open(path).bufferedReader().use { it.readText() } + RuleResolver.validate(json.decodeFromString(ManufacturerConfig.serializer(), content)) + } catch (_: FileNotFoundException) { + null + } + } + + private companion object { + const val ASSET_DIRECTORY = "legitkeepalive" + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/StatusChecker.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/StatusChecker.kt new file mode 100644 index 0000000..a421f77 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/StatusChecker.kt @@ -0,0 +1,51 @@ +package io.github.ityulong.legitkeepalive.internal + +import android.app.AppOpsManager +import android.content.Context +import android.net.ConnectivityManager +import android.os.PowerManager +import android.os.Process +import android.provider.Settings + +internal object StatusChecker { + + private const val OP_XIAOMI_BACKGROUND_POPUP = 10021 + private const val OP_XIAOMI_SHOW_WHEN_LOCKED = 10020 + + fun check(id: String, context: Context): Boolean? { + return when (id) { + "battery_optimization" -> isIgnoringBatteryOptimizations(context) + "background_data" -> isBackgroundDataAllowed(context) + "alert_window" -> Settings.canDrawOverlays(context) + "xiaomi_background_popup" -> checkXiaomiOp(context, OP_XIAOMI_BACKGROUND_POPUP) + "xiaomi_lock_popup" -> checkXiaomiOp(context, OP_XIAOMI_SHOW_WHEN_LOCKED) + else -> null + } + } + + private fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val powerManager = context.getSystemService(PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + private fun isBackgroundDataAllowed(context: Context): Boolean { + val connectivityManager = + context.getSystemService(ConnectivityManager::class.java) ?: return false + return connectivityManager.restrictBackgroundStatus != + ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED + } + + private fun checkXiaomiOp(context: Context, operation: Int): Boolean { + val appOpsManager = context.getSystemService(AppOpsManager::class.java) ?: return false + return runCatching { + val method = appOpsManager.javaClass.getMethod( + "checkOpNoThrow", + Int::class.java, + Int::class.java, + String::class.java + ) + method.invoke(appOpsManager, operation, Process.myUid(), context.packageName) == + AppOpsManager.MODE_ALLOWED + }.getOrDefault(false) + } +} diff --git a/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SystemPropertyReader.kt b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SystemPropertyReader.kt new file mode 100644 index 0000000..93f0343 --- /dev/null +++ b/keepalive-core/src/main/java/io/github/ityulong/legitkeepalive/internal/SystemPropertyReader.kt @@ -0,0 +1,12 @@ +package io.github.ityulong.legitkeepalive.internal + +internal object SystemPropertyReader { + + fun get(key: String): String? { + return runCatching { + val systemProperties = Class.forName("android.os.SystemProperties") + val get = systemProperties.getMethod("get", String::class.java, String::class.java) + (get.invoke(null, key, "") as? String)?.takeIf(String::isNotBlank) + }.getOrNull() + } +} diff --git a/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/LegitKeepAliveTest.kt b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/LegitKeepAliveTest.kt new file mode 100644 index 0000000..cabce3b --- /dev/null +++ b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/LegitKeepAliveTest.kt @@ -0,0 +1,54 @@ +package io.github.ityulong.legitkeepalive + +import android.provider.Settings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.shadows.ShadowBuild + +@RunWith(RobolectricTestRunner::class) +class LegitKeepAliveTest { + + @Test + fun recommendationsLoadDefaultRulesForUnknownManufacturer() { + ShadowBuild.setManufacturer("unknown-vendor") + val context = RuntimeEnvironment.getApplication() + + val recommendations = LegitKeepAlive.create(context).recommendations() + + assertEquals(3, recommendations.size) + assertTrue(recommendations.any { it.id == "battery_optimization" }) + assertTrue(recommendations.any { it.id == "background_data" }) + assertTrue(recommendations.any { it.id == "alert_window" }) + } + + @Test + fun allBundledManufacturerConfigurationsAreValid() { + val context = RuntimeEnvironment.getApplication() + + listOf("xiaomi", "honor").forEach { manufacturer -> + ShadowBuild.setManufacturer(manufacturer) + assertTrue( + "$manufacturer configuration should contain recommendations", + LegitKeepAlive.create(context).recommendations().isNotEmpty() + ) + } + } + + @Test + fun recommendationCreatesSettingsIntentThroughPublicApi() { + ShadowBuild.setManufacturer("unknown-vendor") + val context = RuntimeEnvironment.getApplication() + val keepAlive = LegitKeepAlive.create(context) + val recommendation = keepAlive.recommendations() + .first { it.id == "battery_optimization" } + + val intent = keepAlive.createSettingsIntent(recommendation) + + assertEquals(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, intent?.action) + assertEquals("package:${context.packageName}", intent?.data.toString()) + } +} diff --git a/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/RuleResolverTest.kt b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/RuleResolverTest.kt new file mode 100644 index 0000000..b3544b0 --- /dev/null +++ b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/RuleResolverTest.kt @@ -0,0 +1,99 @@ +package io.github.ityulong.legitkeepalive.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class RuleResolverTest { + + @Test + fun resolveUsesVersionSpecificSettingsWhenRomVersionMatches() { + val fallback = setting("fallback") + val matched = setting("matched") + val config = ManufacturerConfig( + comment = "test", + defaultSettings = listOf(fallback), + versionSpecifics = listOf( + VersionSpecific( + versionMatchesRegex = "^V12.*", + comment = "MIUI 12", + settings = listOf(matched) + ) + ) + ) + + assertEquals(listOf(matched), RuleResolver.resolve(config, "V12.5")) + } + + @Test + fun resolveUsesDefaultSettingsWhenRomVersionDoesNotMatch() { + val fallback = setting("fallback") + val config = ManufacturerConfig( + comment = "test", + defaultSettings = listOf(fallback), + versionSpecifics = listOf( + VersionSpecific( + versionMatchesRegex = "^V12.*", + comment = "MIUI 12", + settings = listOf(setting("matched")) + ) + ) + ) + + assertEquals(listOf(fallback), RuleResolver.resolve(config, "V14.0")) + } + + @Test + fun validateRejectsDuplicateSettingIds() { + val duplicate = setting("duplicate") + + expectIllegalArgument { + RuleResolver.validate(listOf(duplicate, duplicate.copy(title = "second"))) + } + } + + @Test + fun validateRejectsSettingsWithoutNavigationInstruction() { + val invalid = setting("invalid").copy(intent = IntentSpec()) + + expectIllegalArgument { + RuleResolver.validate(listOf(invalid)) + } + } + + @Test + fun validateRejectsInvalidRomVersionRegex() { + val config = ManufacturerConfig( + comment = "test", + defaultSettings = listOf(setting("fallback")), + versionSpecifics = listOf( + VersionSpecific( + versionMatchesRegex = "[", + comment = "invalid", + settings = listOf(setting("matched")) + ) + ) + ) + + expectIllegalArgument { + RuleResolver.validate(config) + } + } + + private fun setting(id: String) = SettingRule( + id = id, + title = id, + description = "$id description", + isCheckable = false, + intent = IntentSpec(action = "android.settings.APPLICATION_DETAILS_SETTINGS") + ) + + private fun expectIllegalArgument(block: () -> Unit) { + try { + block() + fail("Expected IllegalArgumentException") + } catch (_: IllegalArgumentException) { + // Expected. + } + } +} diff --git a/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactoryTest.kt b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactoryTest.kt new file mode 100644 index 0000000..700192e --- /dev/null +++ b/keepalive-core/src/test/java/io/github/ityulong/legitkeepalive/internal/SettingsIntentFactoryTest.kt @@ -0,0 +1,55 @@ +package io.github.ityulong.legitkeepalive.internal + +import android.Manifest +import android.content.ComponentName +import android.provider.Settings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsIntentFactoryTest { + + @Test + fun createBuildsBatteryOptimizationRequestForConsumerPackage() { + val intent = SettingsIntentFactory.create( + packageName = "example.consumer", + spec = IntentSpec(permission = Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + ) + + assertEquals(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, intent?.action) + assertEquals("package:example.consumer", intent?.data.toString()) + } + + @Test + fun createBuildsConfiguredComponentAndPackageExtras() { + val intent = SettingsIntentFactory.create( + packageName = "example.consumer", + spec = IntentSpec(component = "com.vendor.settings/.BackgroundActivity") + ) + + assertEquals( + ComponentName("com.vendor.settings", "com.vendor.settings.BackgroundActivity"), + intent?.component + ) + assertEquals("example.consumer", intent?.getStringExtra("package_name")) + assertEquals("example.consumer", intent?.getStringExtra("extra_pkgname")) + } + + @Test + fun createReturnsNullWhenNoNavigationInstructionExists() { + assertNull(SettingsIntentFactory.create("example.consumer", IntentSpec())) + } + + @Test + fun createReturnsNullWhenComponentNameIsInvalid() { + assertNull( + SettingsIntentFactory.create( + packageName = "example.consumer", + spec = IntentSpec(component = "not-a-component") + ) + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c4d158a..f374631 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,4 +23,4 @@ dependencyResolutionManagement { rootProject.name = "KeepAlive" include(":app") - \ No newline at end of file +include(":keepalive-core")