Skip to content

Commit ac1f62f

Browse files
committed
feat: Added a resolvable ContextWrapper chain, enhanced dialog display logic, avoided self-referencing cycles, and logged diagnostics.
1 parent c78a408 commit ac1f62f

4 files changed

Lines changed: 136 additions & 8 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/ktx/Dialogs.kt

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.ktx
22

33
import android.app.Activity
44
import android.content.Context
5+
import android.content.ContextWrapper
56
import androidx.appcompat.app.AlertDialog
67
import androidx.fragment.app.Fragment
78
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -16,13 +17,53 @@ fun Context.alert(text: String): AlertDialog {
1617

1718
fun Fragment.alert(text: String) = requireContext().alert(text)
1819

20+
internal fun Context.resolveActivity(): WrappedHostResolution<Activity> = resolveWrappedHost(
21+
initial = this,
22+
hostOrNull = { it as? Activity },
23+
baseOrNull = { (it as? ContextWrapper)?.baseContext },
24+
)
25+
1926
fun AlertDialog.tryToShow() {
27+
val initialContext = context
28+
val resolution = initialContext.resolveActivity()
29+
val activity = resolution.host
30+
if (activity == null) {
31+
Logs.w(
32+
"AlertDialog.tryToShow skipped: no Activity host; " +
33+
"initialContext=${initialContext.javaClass.name}, " +
34+
"wrapperDepth=${resolution.wrapperDepth}, " +
35+
"loopDetected=${resolution.loopDetected}"
36+
)
37+
return
38+
}
39+
if (activity.isFinishing) {
40+
Logs.w(
41+
"AlertDialog.tryToShow skipped: Activity is finishing; " +
42+
"initialContext=${initialContext.javaClass.name}, " +
43+
"wrapperDepth=${resolution.wrapperDepth}, " +
44+
"activity=${activity.javaClass.name}"
45+
)
46+
return
47+
}
48+
if (activity.isDestroyed) {
49+
Logs.w(
50+
"AlertDialog.tryToShow skipped: Activity is destroyed; " +
51+
"initialContext=${initialContext.javaClass.name}, " +
52+
"wrapperDepth=${resolution.wrapperDepth}, " +
53+
"activity=${activity.javaClass.name}"
54+
)
55+
return
56+
}
57+
58+
Logs.i(
59+
"AlertDialog.tryToShow resolved host: " +
60+
"initialContext=${initialContext.javaClass.name}, " +
61+
"wrapperDepth=${resolution.wrapperDepth}, " +
62+
"activity=${activity.javaClass.name}"
63+
)
2064
try {
21-
val activity = context as Activity
22-
if (!activity.isFinishing) {
23-
show()
24-
}
65+
show()
2566
} catch (e: Exception) {
26-
Logs.e(e)
67+
Logs.e("AlertDialog.tryToShow failed while showing on a resolved Activity", e)
2768
}
2869
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.nekohasekai.sagernet.ktx
2+
3+
import java.util.Collections
4+
import java.util.IdentityHashMap
5+
6+
internal data class WrappedHostResolution<Host : Any>(
7+
val host: Host?,
8+
val wrapperDepth: Int,
9+
val loopDetected: Boolean,
10+
)
11+
12+
internal fun <Node : Any, Host : Any> resolveWrappedHost(
13+
initial: Node,
14+
hostOrNull: (Node) -> Host?,
15+
baseOrNull: (Node) -> Node?,
16+
): WrappedHostResolution<Host> {
17+
val visited = Collections.newSetFromMap(IdentityHashMap<Node, Boolean>())
18+
var current = initial
19+
var wrapperDepth = 0
20+
21+
while (visited.add(current)) {
22+
hostOrNull(current)?.let {
23+
return WrappedHostResolution(it, wrapperDepth, loopDetected = false)
24+
}
25+
current = baseOrNull(current)
26+
?: return WrappedHostResolution(null, wrapperDepth, loopDetected = false)
27+
wrapperDepth++
28+
}
29+
30+
return WrappedHostResolution(null, wrapperDepth, loopDetected = true)
31+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package io.nekohasekai.sagernet.ktx
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertSame
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
10+
class WrappedHostResolverTest {
11+
12+
private open class FakeContext
13+
14+
private class FakeActivity : FakeContext()
15+
16+
private class FakeContextWrapper(var base: FakeContext) : FakeContext()
17+
18+
private fun resolve(context: FakeContext) = resolveWrappedHost(
19+
initial = context,
20+
hostOrNull = { it as? FakeActivity },
21+
baseOrNull = { (it as? FakeContextWrapper)?.base },
22+
)
23+
24+
@Test
25+
fun themedContextWrappersResolveActivityHost() {
26+
val activity = FakeActivity()
27+
val themedContext = FakeContextWrapper(FakeContextWrapper(activity))
28+
29+
val resolution = resolve(themedContext)
30+
31+
assertSame(activity, resolution.host)
32+
assertEquals(2, resolution.wrapperDepth)
33+
assertFalse(resolution.loopDetected)
34+
}
35+
36+
@Test
37+
fun contextWithoutActivityReturnsNoHost() {
38+
val resolution = resolve(FakeContextWrapper(FakeContext()))
39+
40+
assertNull(resolution.host)
41+
assertEquals(1, resolution.wrapperDepth)
42+
assertFalse(resolution.loopDetected)
43+
}
44+
45+
@Test
46+
fun selfReferencingWrapperDoesNotLoop() {
47+
val wrapper = FakeContextWrapper(FakeContext())
48+
wrapper.base = wrapper
49+
50+
val resolution = resolve(wrapper)
51+
52+
assertNull(resolution.host)
53+
assertEquals(1, resolution.wrapperDepth)
54+
assertTrue(resolution.loopDetected)
55+
}
56+
}

openspec/changes/fix-dialog-context-cast/tasks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
## 1. 共享对话框诊断与修复批次
22

3-
- [ ] 1.1 在共享对话框工具中加入可测试的 ContextWrapper 链解析,防止自引用 wrapper 循环;为无宿主、finishing、destroyed 与显示异常分别加入不含错误正文的定向日志,以验证主题 wrapper 可解析到页面 Activity 的诊断假设。
4-
- [ ] 1.2 更新安全显示逻辑,仅在解析到未 finishing/未 destroyed 的 Activity 时显示,并保持现有异常日志作为生命周期竞态的最后防线;不改动规则更新和配置状态调用方的业务流程。
5-
- [ ] 1.3 补充自动化回归覆盖:主题包装 Context 可解析并显示、无 Activity Context 安全跳过、自引用 wrapper 不循环,以及不可用 Activity 不显示;若现有单元测试环境无法可靠构造 Android 窗口,则保留纯 Context 解析测试,并把显示行为纳入下一真机闸门。
3+
- [x] 1.1 在共享对话框工具中加入可测试的 ContextWrapper 链解析,防止自引用 wrapper 循环;为无宿主、finishing、destroyed 与显示异常分别加入不含错误正文的定向日志,以验证主题 wrapper 可解析到页面 Activity 的诊断假设。
4+
- [x] 1.2 更新安全显示逻辑,仅在解析到未 finishing/未 destroyed 的 Activity 时显示,并保持现有异常日志作为生命周期竞态的最后防线;不改动规则更新和配置状态调用方的业务流程。
5+
- [x] 1.3 补充自动化回归覆盖:主题包装 Context 可解析并显示、无 Activity Context 安全跳过、自引用 wrapper 不循环,以及不可用 Activity 不显示;若现有单元测试环境无法可靠构造 Android 窗口,则保留纯 Context 解析测试,并把显示行为纳入下一真机闸门。
66
- [ ] 1.4 提交该最小批次后立即运行 GitHub Actions `CI / Build OSS APK`(执行 `app:testOssDebugUnitTest``app:assembleOssDebug`);预期测试及 APK 编译通过,最低回传证据为 workflow/job 链接和失败时的完整 Gradle 错误片段。在结果返回前不继续追加实现改动。
77

88
## 2. 真机行为验证与规范收口

0 commit comments

Comments
 (0)