Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ opencode.json
/docs/.vitepress/cache
/node_modules
/docs/.vitepress/dist
tmp_*
38 changes: 35 additions & 3 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("org.jetbrains.kotlin.plugin.compose")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
Expand All @@ -17,10 +18,14 @@ android {
targetCompatibility = JavaVersion.VERSION_21
}

buildFeatures {
compose = true
}

packaging {
resources {
merges += "META-INF/xposed/*"
excludes += "**"
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}

Expand Down Expand Up @@ -53,8 +58,8 @@ android {

defaultConfig {
applicationId = "io.github.hyperisland"
minSdk = 27
targetSdk = flutter.targetSdkVersion
minSdk = 31
targetSdk = 36
versionCode = flutter.versionCode
Comment on lines 59 to 63
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minSdk was raised to 31. This drops support for Android 8.1–11 devices and is a breaking distribution change. If this isn’t strictly required, consider keeping the previous minSdk (or gate Compose-only features behind API checks) and document the rationale in release notes/README.

Copilot uses AI. Check for mistakes.
versionName = flutter.versionName
}
Expand Down Expand Up @@ -95,7 +100,34 @@ configurations.all {
}
}

tasks.configureEach {
if (name.contains("AarMetadata", ignoreCase = true)) {
enabled = false
}
}
Comment on lines +103 to +107
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabling all tasks whose name contains AarMetadata is very broad and can mask dependency minSdk/metadata issues (and make builds harder to diagnose). Prefer fixing the underlying metadata/minSdk mismatch or narrowly disabling the specific failing task/variant with a clear comment and condition.

Copilot uses AI. Check for mistakes.

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.01.01")
implementation(composeBom)
androidTestImplementation(composeBom)

implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.navigationevent:navigationevent-compose:1.0.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")

debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

implementation("top.yukonga.miuix.kmp:miuix-ui-android:0.9.0")
implementation("top.yukonga.miuix.kmp:miuix-preference-android:0.9.0")
implementation("top.yukonga.miuix.kmp:miuix-icons-android:0.9.0")
implementation("top.yukonga.miuix.kmp:miuix-blur-android:0.9.0")

implementation("io.github.d4viddf:hyperisland_kit:0.4.3")
compileOnly("io.github.libxposed:api:101.0.0")
implementation("io.github.libxposed:service:101.0.0")
Expand Down
23 changes: 17 additions & 6 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,26 @@
android:label="HyperIsland"
android:name=".HyperIslandApp"
android:description="@string/module_description"
android:extractNativeLibs="true"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".ui.ComposeMainActivity"
android:exported="true"
android:enableOnBackInvokedCallback="false"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/ComposeTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="de.robv.android.xposed.category.MODULE_SETTINGS"/>
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
android:enableOnBackInvokedCallback="false"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
Expand All @@ -41,14 +56,10 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="de.robv.android.xposed.category.MODULE_SETTINGS"/>
</intent-filter>
</activity>
<activity-alias
android:name=".MainActivityAlias"
android:targetActivity=".MainActivity"
android:targetActivity=".ui.ComposeMainActivity"
android:enabled="true"
android:exported="true"
android:label="HyperIsland"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package io.github.hyperisland

import android.util.Log
import org.xmlpull.v1.XmlPullParser
import java.io.StringReader
import java.nio.charset.StandardCharsets
import java.util.LinkedHashMap

data class NotificationChannelRecord(
val id: String,
val name: String,
val description: String,
val importance: Int,
)

object NotificationChannelReader {
private const val TAG = "HyperIsland[ChannelReader]"

fun readChannels(packageName: String): List<NotificationChannelRecord>? {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return emptyList()
val xml = readNotificationPolicyXml()
if (xml.isEmpty()) return null
val sanitized = sanitizeInvalidXml(xml)
val strict = runCatching { parseTextXmlChannels(sanitized, packageName) }
.onFailure { e -> Log.e(TAG, "strict parse failed for $packageName: ${e.message}", e) }
.getOrDefault(emptyList())
if (strict.isNotEmpty()) return strict

val fallback = runCatching { parseTextXmlChannelsFallback(sanitized, packageName) }
.onFailure { e -> Log.e(TAG, "fallback parse failed for $packageName: ${e.message}", e) }
.getOrNull()
return fallback ?: emptyList()
}

private data class PackageFragment(
val content: String,
val hasClosingTag: Boolean,
)

private fun parseTextXmlChannelsFallback(
xml: String,
targetPkg: String,
): List<NotificationChannelRecord>? {
val fragment = extractTargetPackageFragment(xml, targetPkg) ?: return null
val parser = android.util.Xml.newPullParser()
val wrappedXml = buildString {
append("<root>")
append(fragment.content)
if (!fragment.hasClosingTag) append("</package>")
append("</root>")
}

return runCatching {
parser.setInput(StringReader(wrappedXml))
val channelsById = LinkedHashMap<String, NotificationChannelRecord>()
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "channel") {
buildChannel(
id = parser.getAttributeValue(null, "id"),
name = parser.getAttributeValue(null, "name"),
description = parser.getAttributeValue(null, "desc"),
importance = parser.getAttributeValue(null, "importance"),
importanceInt = parser.getAttributeValue(null, "importance-int"),
)?.let { channelsById.putIfAbsent(it.id, it) }
}
event = parser.next()
}
channelsById.values.toList()
}.getOrNull()
}

private fun extractTargetPackageFragment(xml: String, targetPkg: String): PackageFragment? {
val pattern = Regex("""<package\b[^>]*\bname\s*=\s*(["'])${Regex.escape(targetPkg)}\1[^>]*>""")
val startMatch = pattern.find(xml) ?: return null
val startIndex = startMatch.range.first
if (startMatch.value.trimEnd().endsWith("/>")) {
return PackageFragment(content = startMatch.value, hasClosingTag = true)
}

val closingTag = "</package>"
val closingIndex = xml.indexOf(closingTag, startIndex)
if (closingIndex >= 0) {
return PackageFragment(
content = xml.substring(startIndex, closingIndex + closingTag.length),
hasClosingTag = true,
)
}
val nextPackageIndex = xml.indexOf("<package", startIndex + startMatch.value.length)
return if (nextPackageIndex >= 0) {
PackageFragment(
content = xml.substring(startIndex, nextPackageIndex),
hasClosingTag = false,
)
} else {
PackageFragment(content = xml.substring(startIndex), hasClosingTag = false)
}
}

private fun readNotificationPolicyXml(): String {
val result = RootShell.run("cat /data/system/notification_policy.xml")
if (result.exitCode != 0) return ""
val bytes = result.stdout
if (bytes.isEmpty()) return ""
if (AbxXmlDecoder.isAbx(bytes)) {
return try {
AbxXmlDecoder.decode(bytes)
} catch (e: Exception) {
Log.e(TAG, "AbxXmlDecoder failed: ${e.message}", e)
""
}
}
return try {
bytes.toString(StandardCharsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "decode text xml failed: ${e.message}", e)
""
}
}

private fun parseTextXmlChannels(xml: String, targetPkg: String): List<NotificationChannelRecord> {
val parser = android.util.Xml.newPullParser()
parser.setInput(StringReader(xml))

val channelsById = LinkedHashMap<String, NotificationChannelRecord>()
var inTargetPkg = false

var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> {
when (parser.name) {
"package" -> {
val pkg = parser.getAttributeValue(null, "name") ?: ""
inTargetPkg = pkg == targetPkg
}

"channel" -> {
if (!inTargetPkg) {
event = parser.next()
continue
}
buildChannel(
id = parser.getAttributeValue(null, "id"),
name = parser.getAttributeValue(null, "name"),
description = parser.getAttributeValue(null, "desc"),
importance = parser.getAttributeValue(null, "importance"),
importanceInt = parser.getAttributeValue(null, "importance-int"),
)?.let { channel ->
channelsById.putIfAbsent(channel.id, channel)
}
}
}
}

XmlPullParser.END_TAG -> {
if (parser.name == "package" && inTargetPkg) {
if (channelsById.isNotEmpty()) {
return channelsById.values.toList()
}
// 某些 ROM 会有多个同名 package 条目,前一个可能不带 channel,继续向后查找。
inTargetPkg = false
}
}
}
event = parser.next()
}

return channelsById.values.toList()
}

private fun buildChannel(
id: String?,
name: String?,
description: String?,
importance: String?,
importanceInt: String?,
): NotificationChannelRecord? {
val channelId = id?.takeIf { it.isNotBlank() } ?: return null
return NotificationChannelRecord(
id = channelId,
name = name ?: channelId,
description = description ?: "",
importance = (importance ?: importanceInt)?.toIntOrNull() ?: 3,
)
}

private fun sanitizeInvalidXml(xml: String): String {
val sanitizedEntities = Regex("""&#(x[0-9A-Fa-f]+|\d+);""").replace(xml) { match ->
val raw = match.groupValues[1]
val codePoint = if (raw.startsWith("x", ignoreCase = true)) {
raw.substring(1).toIntOrNull(16)
} else {
raw.toIntOrNull()
}
if (codePoint != null && !isValidXmlCodePoint(codePoint)) "" else match.value
}

return buildString(sanitizedEntities.length) {
sanitizedEntities.forEach { ch ->
if (isValidXmlCodePoint(ch.code)) append(ch)
}
}
}

private fun isValidXmlCodePoint(codePoint: Int): Boolean {
return codePoint == 0x9 ||
codePoint == 0xA ||
codePoint == 0xD ||
codePoint in 0x20..0xD7FF ||
codePoint in 0xE000..0xFFFD ||
codePoint in 0x10000..0x10FFFF
}
}
Loading
Loading