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.
+[](https://github.com/ityulong/LegitKeepAlive/actions/workflows/android.yml)
+[](https://github.com/ityulong/LegitKeepAlive/releases)
+[](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/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.
+
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")