Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fcb5c5d
feat(nextflux): stabilize image layout and reading gestures in 1.0.12
gkeyes Sep 20, 2026
b02196c
test(nextflux): distinguish gallery and navigation touch handlers
gkeyes Sep 20, 2026
67ee41d
ui: refine shared layouts and interaction feedback
gkeyes Sep 22, 2026
91d0190
test(ui): synchronize disclosure behavior with the Compose clock
gkeyes Sep 22, 2026
09dd867
ci: stop emulator UI tests on UI-polish branch at user request
gkeyes Sep 22, 2026
6567e95
ci: deliver approved UI-polish APK with original release signature
gkeyes Sep 22, 2026
1ad8b4f
ci: parse Build Tools 37 certificate reports and match release signin…
gkeyes Sep 22, 2026
2f6aae3
feat(ui): polish high-frequency catalog and import flows with lazy ma…
gkeyes Sep 22, 2026
a4b65a3
fix(ui): carry optional description through verified installed manifest
gkeyes Sep 22, 2026
1be5226
feat(ui): polish secondary pages and expose read-only permission summ…
gkeyes Sep 22, 2026
347360b
fix(ui): settle sheet position when dismissal is intercepted
gkeyes Sep 22, 2026
b818122
feat(ui): complete secondary polish with contextual runtime feedback
gkeyes Sep 22, 2026
83b90e0
ci: sign latest stage-two Release APK with the original certificate
gkeyes Sep 22, 2026
616b49f
ci: prepare isolated source audit for background reliability fixes
gkeyes Sep 22, 2026
3c0795b
fix: recover background runtimes and unblock GitHub watcher polling
gkeyes Sep 22, 2026
4dfce09
ci: export exact branch histories for consolidation audit
gkeyes Sep 22, 2026
0f8e51e
ci: prepare audited consolidation tree without changing the default b…
gkeyes Sep 22, 2026
31cbacd
ci: apply reviewed script by immutable blob and verify the final tree
gkeyes Sep 22, 2026
4335e3f
fix: audit and consolidate UI, watcher recovery and NextFlux reading …
gkeyes Sep 22, 2026
d376458
test: adapt isolated watcher layout fixtures to reliable startup and …
gkeyes Sep 22, 2026
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
45 changes: 38 additions & 7 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ on:
- "scripts/tests/test_release_startup.py"
workflow_dispatch:
inputs:
run_android_ui_tests:
description: "Explicitly run Android emulator behavior and signed-startup checks"
type: boolean
default: false
validation_scope:
description: "Checks to run: full suite or catalog/icon/root-state/network performance regressions"
type: choice
Expand All @@ -61,23 +65,44 @@ jobs:
verification_scope: ${{ steps.baseline.outputs.scope }}
verification_run: ${{ steps.baseline.outputs.source_run }}
verification_commit: ${{ steps.baseline.outputs.base_commit }}
verification_checks: ${{ steps.baseline.outputs.checks }}
verification_checks: ${{ steps.android_ui.outputs.enabled == 'false' && 'build_and_unit_only' || steps.baseline.outputs.checks }}
android_ui_enabled: ${{ steps.android_ui.outputs.enabled }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
fetch-depth: 0
persist-credentials: false
- name: Select explicit Android UI test policy
id: android_ui
env:
RUN_ANDROID_UI: ${{ inputs.run_android_ui_tests == true }}
run: |
if [ "$RUN_ANDROID_UI" = true ]; then
echo 'enabled=true' >> "$GITHUB_OUTPUT"
else
echo 'enabled=false' >> "$GITHUB_OUTPUT"
printf '%s\n' '## Android emulator checks: NOT RUN' \
'User-requested default: APK compilation, JVM, JS and contract checks only.' \
'Neither Android behavior nor signed-startup checks ran; no full-device validation is claimed.' \
'Manual workflow runs can explicitly enable run_android_ui_tests.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Select current or unchanged host verification evidence
id: baseline
env:
GH_TOKEN: ${{ github.token }}
REUSE_VERIFIED_RUN: ${{ inputs.reuse_verified_run }}
REUSE_VERIFIED_RUN: ${{ steps.android_ui.outputs.enabled == 'true' && inputs.reuse_verified_run || '' }}
VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: python3 scripts/ci/reuse-host-verification.py
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Verify watcher polling and stopped-session regressions
run: node --test scripts/tests/watcher-reliability.test.cjs
- name: Verify release evidence and baseline reuse guards
run: |
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_release_startup.py' -v
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_release_scope.py' -v
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_reuse_host_verification.py' -v
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
if: ${{ steps.baseline.outputs.scope == 'current_run' }}
Expand All @@ -97,12 +122,15 @@ jobs:
- name: Verify Wasm binary packaging
if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.baseline.outputs.checks == 'full' }}
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p 'test_package_tool.py' -v
- name: ${{ steps.baseline.outputs.checks == 'performance' && 'Compile performance test APK' || 'Build APKs and behavioral test APKs' }}
- name: ${{ steps.android_ui.outputs.enabled == 'false' && 'Build APKs (Android UI tests disabled)' || (steps.baseline.outputs.checks == 'performance' && 'Compile performance test APK' || 'Build APKs and behavioral test APKs') }}
if: ${{ steps.baseline.outputs.scope == 'current_run' }}
env:
VALIDATION_SCOPE: ${{ steps.baseline.outputs.checks }}
SKIP_ANDROID_UI: ${{ steps.android_ui.outputs.enabled == 'false' }}
run: |
if [ "$VALIDATION_SCOPE" = performance ]; then
if [ "$SKIP_ANDROID_UI" = true ]; then
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
elif [ "$VALIDATION_SCOPE" = performance ]; then
./gradlew --no-daemon :app:assembleDebug :app:compileDebugAndroidTestKotlin
else
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease :app:compileDebugAndroidTestKotlin :tool-runtime:compileDebugAndroidTestKotlin
Expand All @@ -121,13 +149,13 @@ jobs:
./gradlew --no-daemon :core-data:testDebugUnitTest :tool-package:testDebugUnitTest :tool-runtime:testDebugUnitTest :app:testDebugUnitTest
fi
- name: Enable accelerated behavior test emulator
if: ${{ steps.baseline.outputs.scope == 'current_run' }}
if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' }}
run: |
printf '%s\n' 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: ${{ steps.baseline.outputs.checks == 'performance' && 'Verify performance behavior on Android' || 'Verify catalog, backup, execution identity, dialogs and Wasm on Android' }}
if: ${{ steps.baseline.outputs.scope == 'current_run' }}
if: ${{ steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' }}
id: runtime_emulator
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
env:
Expand Down Expand Up @@ -161,7 +189,7 @@ jobs:
if-no-files-found: ignore
retention-days: 14
- name: Retain Android behavior and WebView evidence
if: ${{ always() && steps.baseline.outputs.scope == 'current_run' && steps.runtime_emulator.outcome != 'skipped' }}
if: ${{ always() && steps.baseline.outputs.scope == 'current_run' && steps.android_ui.outputs.enabled == 'true' && steps.runtime_emulator.outcome != 'skipped' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: wasm-webview-${{ github.sha }}
Expand Down Expand Up @@ -234,11 +262,13 @@ jobs:
unzip -p "$apk" "assets/$example.tbx" | cmp - "build/bundled-examples/$example.tbx"
done
- name: Enable accelerated release startup emulator
if: ${{ needs.verify.outputs.android_ui_enabled == 'true' }}
run: |
printf '%s\n' 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cold-start the signed release and verify its initial home
if: ${{ needs.verify.outputs.android_ui_enabled == 'true' }}
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d
with:
api-level: 35
Expand All @@ -251,6 +281,7 @@ jobs:
- name: Verify release certificate identity and optimization then assemble delivery
env:
VERIFY_JOB_RESULT: ${{ needs.verify.result }}
ALLOW_UNTESTED_STARTUP: ${{ needs.verify.outputs.android_ui_enabled == 'false' && 'true' || 'false' }}
HOST_VERIFICATION_SCOPE: ${{ needs.verify.outputs.verification_scope }}
HOST_VERIFICATION_RUN: ${{ needs.verify.outputs.verification_run }}
HOST_VERIFICATION_COMMIT: ${{ needs.verify.outputs.verification_commit }}
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/tbx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ jobs:
- if: ${{ matrix.npm }}
working-directory: examples/${{ matrix.tool }}
run: npm ci --ignore-scripts
- name: Watcher reliability regressions
if: ${{ matrix.tool == 'github-actions-watcher' }}
run: node --test scripts/tests/watcher-reliability.test.cjs
- name: NextFlux reading unit tests
if: ${{ matrix.tool == 'nextflux' }}
working-directory: examples/nextflux
run: npm test
- name: Prepare NextFlux browser checks
if: ${{ matrix.tool == 'nextflux' }}
working-directory: examples/nextflux/test/browser
run: |
npm ci --ignore-scripts
npx playwright install --with-deps chromium
- name: NextFlux reading browser checks
if: ${{ matrix.tool == 'nextflux' }}
working-directory: examples/nextflux/test/browser
run: npm test
- name: Save NextFlux reading test results
if: ${{ always() && matrix.tool == 'nextflux' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: nextflux-reading-checks-${{ github.sha }}
path: examples/nextflux/test/browser/test-results/reading-results.json
if-no-files-found: warn
- env:
TBX_TOOL: ${{ matrix.tool }}
run: python3 scripts/ci/tbx.py build --tool "$TBX_TOOL"
Expand Down
227 changes: 227 additions & 0 deletions app/src/androidTest/kotlin/io/toolbox/host/DesignPolishBehaviorTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package io.toolbox.host

import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.toolbox.core.ui.component.*
import io.toolbox.core.ui.theme.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

@RunWith(Parameterized::class)
class DesignPolishBehaviorTest(
private val style: ToolBoxThemeStyle,
private val mode: ToolBoxThemeMode,
private val reduceTransparency: Boolean,
) {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private val viewportWidth = mutableFloatStateOf(360f)
private val fontScale = mutableFloatStateOf(1f)
private val direction = mutableStateOf(LayoutDirection.Ltr)

private fun render(content: @Composable () -> Unit) {
compose.activity.setContent {
BoxWithConstraints(Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalDensity provides Density(constraints.maxWidth / viewportWidth.floatValue, fontScale.floatValue),
LocalLayoutDirection provides direction.value,
) {
ToolBoxTheme(style = style, mode = mode, reduceTransparency = reduceTransparency) {
Column(Modifier.fillMaxSize(), content = { content() })
}
}
}
}
}

@Test
fun titleAvoidsAllControlsAtNarrowWidthsAndLargeFontsInBothDirections() {
val title = "工具详情与运行设置"
var clicks = 0
render {
ToolBoxTopBar(
title = title,
subtitle = "已安装工具",
modifier = Modifier.testTag("topbar"),
navigationIcon = ToolBoxIconKey.Back,
onNavigationClick = { clicks++ },
actions = {
repeat(3) { index ->
ToolBoxIconButton(ToolBoxIconKey.More, "操作$index", { clicks++ })
}
},
)
}
val controls = listOf("返回", "操作0", "操作1", "操作2")
for (layoutDirection in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) {
for ((width, scale) in listOf(320f to 1f, 320f to 1.6f, 360f to 1f, 600f to 1f)) {
compose.runOnIdle {
viewportWidth.floatValue = width
fontScale.floatValue = scale
direction.value = layoutDirection
}
val bar = bounds("topbar")
val titleBounds = compose.onNodeWithText(title).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue("The title must retain visible space", titleBounds.width > 0f)
assertContained(titleBounds, bar)
val controlBounds = controls.map { description ->
compose.onNodeWithContentDescription(description)
.assertHasClickAction().assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp)
.fetchSemanticsNode().boundsInRoot.also { assertContained(it, bar) }
}
controlBounds.forEach { control ->
assertTrue("Title overlaps a control", titleBounds.right <= control.left + 1f || titleBounds.left >= control.right - 1f)
}
for (i in controlBounds.indices) for (j in i + 1 until controlBounds.size) {
val a = controlBounds[i]
val b = controlBounds[j]
assertTrue("Controls overlap", a.right <= b.left + 1f || b.right <= a.left + 1f)
}
if (width == 600f) assertEquals("Wide titles stay screen-centered", bar.center.x, titleBounds.center.x, 1.5f)
}
}
controls.forEach { compose.onNodeWithContentDescription(it).performClick() }
compose.runOnIdle { assertEquals(4, clicks) }
}

@Test
fun searchTextWidthDoesNotJumpWhenTheClearControlAppears() {
val query = mutableStateOf("")
render { ToolBoxSearchField(query.value, { query.value = it }, "搜索工具", Modifier.testTag("search")) }
for (layoutDirection in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) {
compose.runOnIdle { direction.value = layoutDirection; query.value = "" }
val empty = compose.onNodeWithContentDescription("搜索工具").fetchSemanticsNode().boundsInRoot
val beforeHeight = bounds("search").height
compose.onNodeWithContentDescription("清空搜索工具").assertDoesNotExist()
compose.runOnIdle { query.value = "RSS" }
val filled = compose.onNodeWithContentDescription("搜索工具").fetchSemanticsNode().boundsInRoot
assertEquals(empty.left, filled.left, 1f)
assertEquals(empty.right, filled.right, 1f)
assertEquals(beforeHeight, bounds("search").height, 1f)
compose.onNodeWithContentDescription("清空搜索工具")
.assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp).performClick()
compose.onNodeWithContentDescription("搜索工具").assertIsFocused()
compose.onNodeWithContentDescription("清空搜索工具").assertDoesNotExist()
}
}

@Test
fun blankSummaryDoesNotCreateAnEmptyLineOrClickableInformationRow() {
val summary = mutableStateOf<String?>(null)
render { ToolBoxSettingRow("存储信息", summary = summary.value, modifier = Modifier.testTag("information")) }
val before = bounds("information").height
compose.onNodeWithTag("information").assertHasNoClickAction()
compose.runOnIdle { summary.value = " \n " }
assertEquals(before, bounds("information").height, 1f)
compose.onNodeWithTag("information").assertHasNoClickAction()
}

@Test
fun disclosureCanBeRetargetedBeforeItsAnimationFinishes() {
val expanded = mutableStateOf(false)
var clicks = 0
render {
ToolBoxDisclosureRow(
title = "高级设置",
expanded = expanded.value,
onClick = {
clicks++
expanded.value = !expanded.value
},
modifier = Modifier.testTag("disclosure"),
)
}
// activity.setContent schedules composition; settle it before freezing the clock.
compose.waitForIdle()
val row = compose.onNodeWithTag("disclosure")
row.assertIsDisplayed().assertHasClickAction()
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))

val previousAutoAdvance = compose.mainClock.autoAdvance
compose.mainClock.autoAdvance = false
try {
row.performClick()
// A paused clock also pauses recomposition, not just the arrow animation.
compose.mainClock.advanceTimeByFrame()
row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
compose.mainClock.advanceTimeBy(32)

row.performClick()
compose.mainClock.advanceTimeByFrame()
row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已折叠"))
compose.mainClock.advanceTimeBy(32)

row.performClick()
compose.mainClock.advanceTimeByFrame()
row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
compose.mainClock.advanceTimeBy(200)
row.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, "已展开"))
} finally {
compose.mainClock.autoAdvance = previousAutoAdvance
}
compose.runOnIdle {
assertEquals(3, clicks)
assertTrue(expanded.value)
}
}

@Test
fun navigationRemainsSelectableWhenItemsReorderAndFontSizeChanges() {
val items = mutableStateOf(listOf(
ToolBoxNavigationItem("tools", "工具", ToolBoxIconKey.Tools, "tab-tools"),
ToolBoxNavigationItem("recent", "最近", ToolBoxIconKey.Clock, "tab-recent"),
ToolBoxNavigationItem("settings", "设置", ToolBoxIconKey.Settings, "tab-settings"),
))
val selectedId = mutableStateOf("tools")
render { ToolBoxNavigationBar(items.value, selectedId.value, { selectedId.value = it.id }) }
compose.onNodeWithTag("tab-settings").performClick().assertIsSelected()
compose.runOnIdle {
items.value = items.value.reversed()
fontScale.floatValue = 1.6f
direction.value = LayoutDirection.Rtl
}
compose.onNodeWithTag("tab-settings").assertIsSelected()
compose.onNodeWithTag("tab-tools").assertHeightIsAtLeast(48.dp).assertWidthIsAtLeast(48.dp)
.performClick().assertIsSelected()
compose.onNodeWithTag("tab-settings").assertIsNotSelected()
}

private fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot

private fun assertContained(inner: Rect, outer: Rect) {
assertTrue("Content escaped the left edge", inner.left >= outer.left - 1f)
assertTrue("Content escaped the right edge", inner.right <= outer.right + 1f)
assertTrue("Content escaped the top edge", inner.top >= outer.top - 1f)
assertTrue("Content escaped the bottom edge", inner.bottom <= outer.bottom + 1f)
}

companion object {
@JvmStatic @Parameterized.Parameters(name = "{0}-{1}-reduce={2}")
fun cases(): List<Array<Any>> = buildList {
for (style in ToolBoxThemeStyle.entries) for (mode in listOf(ToolBoxThemeMode.Light, ToolBoxThemeMode.Dark)) {
add(arrayOf(style, mode, false))
if (style == ToolBoxThemeStyle.LiquidGlass) add(arrayOf(style, mode, true))
}
}
}
}
Loading
Loading