-
Notifications
You must be signed in to change notification settings - Fork 19
Miuix theme #61
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Miuix theme #61
Changes from 7 commits
a7c590a
2c0cf1f
adc6d8b
46d06e6
125db94
349303e
920cf7e
7330f19
910ef47
ed37135
0d49119
038879e
41a38bc
b915620
622327b
0ca8336
44313eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,3 +51,4 @@ opencode.json | |
| /docs/.vitepress/cache | ||
| /node_modules | ||
| /docs/.vitepress/dist | ||
| tmp_* | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
| } | ||
|
|
@@ -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}" | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -53,8 +58,8 @@ android { | |
|
|
||
| defaultConfig { | ||
| applicationId = "io.github.hyperisland" | ||
| minSdk = 27 | ||
| targetSdk = flutter.targetSdkVersion | ||
| minSdk = 31 | ||
| targetSdk = 36 | ||
| versionCode = flutter.versionCode | ||
| versionName = flutter.versionName | ||
| } | ||
|
|
@@ -95,7 +100,34 @@ configurations.all { | |
| } | ||
| } | ||
|
|
||
| tasks.configureEach { | ||
| if (name.contains("AarMetadata", ignoreCase = true)) { | ||
| enabled = false | ||
| } | ||
| } | ||
|
Comment on lines
+103
to
+107
|
||
|
|
||
| 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") | ||
|
|
||
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minSdkwas 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.