diff --git a/package.json b/package.json
index 6857e1d01..c1e38a6f2 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"miniapps:dev:biobridge": "pnpm --filter @biochain/miniapp-biobridge dev"
},
"dependencies": {
+ "@biochain/android-focus-blur-guard": "workspace:*",
"@base-ui/react": "^1.0.0",
"@bfchain/util": "^5.0.0",
"@bfmeta/sign-util": "^1.3.10",
diff --git a/packages/android-focus-blur-guard/package.json b/packages/android-focus-blur-guard/package.json
new file mode 100644
index 000000000..c92bc7e50
--- /dev/null
+++ b/packages/android-focus-blur-guard/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@biochain/android-focus-blur-guard",
+ "version": "0.1.0",
+ "description": "Android focus/blur loop guard for WebView and iframe contexts",
+ "type": "module",
+ "main": "./src/index.ts",
+ "module": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+ },
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "build": "echo 'No build step required'",
+ "typecheck": "tsc --noEmit",
+ "typecheck:run": "tsc --noEmit",
+ "test": "vitest",
+ "test:run": "vitest run --passWithNoTests",
+ "test:storybook": "echo 'No storybook'",
+ "i18n:run": "echo 'No i18n'",
+ "theme:run": "echo 'No theme'"
+ },
+ "devDependencies": {
+ "jsdom": "^27.2.0",
+ "typescript": "^5.9.3",
+ "vitest": "^4.0.0"
+ },
+ "keywords": [
+ "android",
+ "focus",
+ "blur",
+ "guard",
+ "webview"
+ ],
+ "license": "MIT"
+}
diff --git a/packages/android-focus-blur-guard/src/index.test.ts b/packages/android-focus-blur-guard/src/index.test.ts
new file mode 100644
index 000000000..78a8fe690
--- /dev/null
+++ b/packages/android-focus-blur-guard/src/index.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ installAndroidFocusBlurLoopGuard,
+ isAndroidUserAgent,
+ uninstallAndroidFocusBlurLoopGuard,
+} from './index';
+
+describe('android-focus-blur-guard', () => {
+ beforeEach(() => {
+ uninstallAndroidFocusBlurLoopGuard();
+ });
+
+ it('detects android user agent', () => {
+ expect(isAndroidUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8)')).toBe(true);
+ expect(isAndroidUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBe(false);
+ });
+
+ it('suppresses blur for body/html/iframe elements', () => {
+ const nativeSpy = vi.fn();
+ const originalBlur = HTMLElement.prototype.blur;
+ HTMLElement.prototype.blur = function mockNativeBlur(this: HTMLElement): void {
+ nativeSpy(this.tagName);
+ };
+
+ const restore = installAndroidFocusBlurLoopGuard({
+ isAndroid: () => true,
+ });
+
+ document.body.blur();
+ document.documentElement.blur();
+ const iframe = document.createElement('iframe');
+ document.body.appendChild(iframe);
+ iframe.blur();
+
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ input.blur();
+
+ expect(nativeSpy).toHaveBeenCalledTimes(1);
+ expect(nativeSpy).toHaveBeenCalledWith('INPUT');
+
+ restore();
+ HTMLElement.prototype.blur = originalBlur;
+ });
+
+ it('blocks blur event propagation when active element is body-like', () => {
+ installAndroidFocusBlurLoopGuard({
+ isAndroid: () => true,
+ });
+
+ const listener = vi.fn();
+ window.addEventListener('blur', listener);
+
+ window.dispatchEvent(new Event('blur'));
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('does not install on non-android runtime', () => {
+ const nativeBlur = HTMLElement.prototype.blur;
+ const restore = installAndroidFocusBlurLoopGuard({
+ isAndroid: () => false,
+ });
+
+ expect(HTMLElement.prototype.blur).toBe(nativeBlur);
+ restore();
+ });
+});
diff --git a/packages/android-focus-blur-guard/src/index.ts b/packages/android-focus-blur-guard/src/index.ts
new file mode 100644
index 000000000..8a633ea59
--- /dev/null
+++ b/packages/android-focus-blur-guard/src/index.ts
@@ -0,0 +1,137 @@
+export interface AndroidFocusBlurLoopGuardOptions {
+ /**
+ * Android 运行时检测(默认通过 UA 判断)
+ */
+ isAndroid?: () => boolean;
+ /**
+ * 是否将 iframe 元素视为需拦截目标(默认 true)
+ */
+ blockIframeElement?: boolean;
+}
+
+type GuardState = {
+ restore: () => void;
+};
+
+type GuardWindow = Window &
+ typeof globalThis & {
+ __biochainAndroidFocusBlurLoopGuardState__?: GuardState;
+ };
+
+const GUARD_KEY = '__biochainAndroidFocusBlurLoopGuardState__' as const;
+
+function isBlockedElement(element: Element | null, blockIframeElement: boolean): element is HTMLElement {
+ if (!(element instanceof HTMLElement)) {
+ return false;
+ }
+
+ if (element === document.body || element === document.documentElement) {
+ return true;
+ }
+
+ if (blockIframeElement && element instanceof HTMLIFrameElement) {
+ return true;
+ }
+
+ return false;
+}
+
+export function isAndroidUserAgent(userAgent: string): boolean {
+ return /android/i.test(userAgent);
+}
+
+function isAndroidRuntime(): boolean {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+
+ const nav = navigator as Navigator & {
+ userAgentData?: {
+ platform?: string;
+ };
+ };
+
+ const platform = nav.userAgentData?.platform;
+ if (platform && /android/i.test(platform)) {
+ return true;
+ }
+
+ return isAndroidUserAgent(nav.userAgent);
+}
+
+/**
+ * 在 Android WebView 环境为全局 focus/blur 死循环做止血补丁。
+ * - 拦截 window blur/focus 事件在 body/html/iframe 激活时的传播
+ * - 禁止对 body/html/iframe 执行 blur()
+ * - 对 blur() 做最小重入保护
+ */
+export function installAndroidFocusBlurLoopGuard(
+ options: AndroidFocusBlurLoopGuardOptions = {},
+): () => void {
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
+ return () => {};
+ }
+
+ const guardWindow = window as GuardWindow;
+ const existing = guardWindow[GUARD_KEY];
+ if (existing) {
+ return existing.restore;
+ }
+
+ const checkAndroid = options.isAndroid ?? isAndroidRuntime;
+ if (!checkAndroid()) {
+ return () => {};
+ }
+
+ const blockIframeElement = options.blockIframeElement ?? true;
+ const nativeBlur = HTMLElement.prototype.blur;
+ let blurring = false;
+
+ const eventFirewall = (event: Event) => {
+ if (!isBlockedElement(document.activeElement, blockIframeElement)) {
+ return;
+ }
+ event.stopImmediatePropagation();
+ event.stopPropagation();
+ };
+
+ window.addEventListener('blur', eventFirewall, true);
+ window.addEventListener('focus', eventFirewall, true);
+
+ HTMLElement.prototype.blur = function patchedBlur(this: HTMLElement): void {
+ if (isBlockedElement(this, blockIframeElement)) {
+ return;
+ }
+
+ if (blurring) {
+ return;
+ }
+
+ blurring = true;
+ try {
+ nativeBlur.call(this);
+ } finally {
+ queueMicrotask(() => {
+ blurring = false;
+ });
+ }
+ };
+
+ const restore = () => {
+ window.removeEventListener('blur', eventFirewall, true);
+ window.removeEventListener('focus', eventFirewall, true);
+ HTMLElement.prototype.blur = nativeBlur;
+ delete guardWindow[GUARD_KEY];
+ };
+
+ guardWindow[GUARD_KEY] = { restore };
+ return restore;
+}
+
+export function uninstallAndroidFocusBlurLoopGuard(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const guardWindow = window as GuardWindow;
+ guardWindow[GUARD_KEY]?.restore();
+}
diff --git a/packages/android-focus-blur-guard/tsconfig.json b/packages/android-focus-blur-guard/tsconfig.json
new file mode 100644
index 000000000..23334c2f1
--- /dev/null
+++ b/packages/android-focus-blur-guard/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "declarationMap": true,
+ "isolatedModules": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/android-focus-blur-guard/vite.config.ts b/packages/android-focus-blur-guard/vite.config.ts
new file mode 100644
index 000000000..c4588ab8c
--- /dev/null
+++ b/packages/android-focus-blur-guard/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ },
+});
diff --git a/packages/bio-sdk/package.json b/packages/bio-sdk/package.json
index 7e51f88ca..f842fa7f8 100644
--- a/packages/bio-sdk/package.json
+++ b/packages/bio-sdk/package.json
@@ -31,6 +31,7 @@
"vitest": "^4.0.0"
},
"dependencies": {
+ "@biochain/android-focus-blur-guard": "workspace:*",
"zod": "^4.1.13"
},
"keywords": [
diff --git a/packages/bio-sdk/src/index.ts b/packages/bio-sdk/src/index.ts
index 373ec14e0..ef7bf6da7 100644
--- a/packages/bio-sdk/src/index.ts
+++ b/packages/bio-sdk/src/index.ts
@@ -25,6 +25,7 @@ import { BioProviderImpl } from './provider'
import { EthereumProvider, initEthereumProvider } from './ethereum-provider'
import { TronLinkProvider, TronWebProvider, initTronProvider } from './tron-provider'
import type { BioProvider } from './types'
+import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard'
// Re-export types
export * from './types'
@@ -80,6 +81,8 @@ export function initAllProviders(targetOrigin = '*'): {
// Auto-initialize if running in browser
if (typeof window !== 'undefined') {
+ installAndroidFocusBlurLoopGuard()
+
const init = () => {
initBioProvider()
initEthereumProvider()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6016a2ed..ad9423da3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@bfmeta/sign-util':
specifier: ^1.3.10
version: 1.3.10
+ '@biochain/android-focus-blur-guard':
+ specifier: workspace:*
+ version: link:packages/android-focus-blur-guard
'@biochain/bio-sdk':
specifier: workspace:*
version: link:packages/bio-sdk
@@ -714,8 +717,23 @@ importers:
specifier: ^4.0.0
version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2)
+ packages/android-focus-blur-guard:
+ devDependencies:
+ jsdom:
+ specifier: ^27.2.0
+ version: 27.3.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.0
+ version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2)
+
packages/bio-sdk:
dependencies:
+ '@biochain/android-focus-blur-guard':
+ specifier: workspace:*
+ version: link:../android-focus-blur-guard
zod:
specifier: ^4.1.13
version: 4.2.1
@@ -12120,7 +12138,7 @@ snapshots:
'@vitest/mocker': 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
playwright: 1.57.0
tinyrainbow: 3.0.3
- vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2)
+ vitest: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
diff --git a/src/main.tsx b/src/main.tsx
index 17feff8ad..c1660b04a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,10 +2,13 @@
import './lib/error-capture'
import './lib/superjson'
import './polyfills'
+import { installAndroidFocusBlurLoopGuard } from '@biochain/android-focus-blur-guard'
import { startServiceMain } from './service-main'
import { startFrontendMain } from './frontend-main'
import { shouldBlockContextMenu } from './lib/context-menu-guard'
+installAndroidFocusBlurLoopGuard()
+
// 禁用右键菜单(移动端 App 体验)
document.addEventListener('contextmenu', (event) => {
if (!shouldBlockContextMenu(event.target)) {
diff --git a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx
index 256ed7e05..453abfcb9 100644
--- a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx
+++ b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx
@@ -402,6 +402,12 @@ describe('miniapp confirm jobs regressions', () => {
expect(confirmButton).not.toBeDisabled();
});
+ expect(screen.getByTestId('miniapp-transfer-remark')).toBeInTheDocument();
+ expect(screen.getByText('ex_type')).toBeInTheDocument();
+ expect(screen.getByText('exchange.purchase')).toBeInTheDocument();
+ expect(screen.getByText('ex_id')).toBeInTheDocument();
+ expect(screen.getByText('exchange-001')).toBeInTheDocument();
+
fireEvent.click(confirmButton);
fireEvent.click(screen.getByTestId('pattern-lock'));
diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx
index 2199410cf..1b62d9ab6 100644
--- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx
+++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx
@@ -160,6 +160,18 @@ function MiniappTransferConfirmJobContent() {
() => (parsedAmount ? null : t('transaction:broadcast.invalidParams')),
[parsedAmount, t],
);
+ const remarkEntries = useMemo(() => {
+ if (!remark) {
+ return [] as Array<{ key: string; value: string }>;
+ }
+
+ return Object.entries(remark)
+ .map(([key, value]) => ({
+ key: key.trim(),
+ value: value.trim(),
+ }))
+ .filter((entry) => entry.key.length > 0 && entry.value.length > 0);
+ }, [remark]);
const transferShortTitle = useMemo(
() => t('transaction:miniappTransfer.shortTitle', { amount: displayAmount, asset: displayAsset }),
@@ -567,8 +579,8 @@ function MiniappTransferConfirmJobContent() {
setSuccessCountdown(SUCCESS_CLOSE_SECONDS);
}
} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- logTransferSheet('transfer.failed', { inputStep, message });
+ const errorType = error instanceof Error ? error.name : typeof error;
+ logTransferSheet('transfer.failed', { inputStep, errorType });
if (inputStep === 'two_step_secret') {
console.error('[miniapp-transfer][two-step-secret]', error);
@@ -733,6 +745,26 @@ function MiniappTransferConfirmJobContent() {
{t('transferWarning')}
@@ -900,6 +932,26 @@ function MiniappTransferConfirmJobContent() {