Files
openless/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx
T
2026-07-15 13:20:57 +08:00

387 lines
17 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../../../src/components/Icon';
import { getSettings, setSettings } from '../../../src/lib/ipc/settings';
import type { UserPreferences } from '../../../src/lib/types';
import { Btn, Pill } from '../../../src/pages/_atoms';
import { SettingRow } from '../../../src/pages/settings/shared';
import {
getAndroidAccessibilityStatus,
getAndroidOverlayStatus,
requestAndroidAccessibilityPermission,
requestAndroidOverlayPermission,
} from '../lib/androidIpc';
import type {
AndroidAccessibilityStatus,
AndroidInsertStrategy,
AndroidOverlayActivationMode,
AndroidOverlayCancelSwipeDirection,
AndroidOverlayLeftSwipeAction,
AndroidOverlayStatus,
AndroidOverlayTrigger,
AndroidPreferenceKey,
} from '../lib/androidTypes';
import {
clampAndroidOverlaySize,
normalizeAndroidOverlayTrigger,
} from '../lib/androidTypes';
type AndroidPrefsSlice = Pick<UserPreferences, AndroidPreferenceKey>;
function pickAndroidPrefs(settings: UserPreferences): AndroidPrefsSlice {
return {
androidInsertStrategy: settings.androidInsertStrategy,
androidOverlayTrigger: settings.androidOverlayTrigger,
androidOverlayActivationMode: settings.androidOverlayActivationMode,
androidOverlayLeftSwipeAction: settings.androidOverlayLeftSwipeAction,
androidOverlayCancelSwipeDirection: settings.androidOverlayCancelSwipeDirection,
androidOverlaySizeDp: settings.androidOverlaySizeDp,
};
}
let androidOverlaySaveQueue = Promise.resolve();
function enqueueAndroidOverlaySave<T>(task: () => Promise<T>): Promise<T> {
const run = androidOverlaySaveQueue.then(task);
androidOverlaySaveQueue = run.then(() => undefined, () => undefined);
return run;
}
function persistAndroidOverlayPrefs(patch: Partial<UserPreferences>): Promise<AndroidPrefsSlice> {
return enqueueAndroidOverlaySave(async () => {
const settings = await getSettings();
const next = {
...settings,
...patch,
};
await setSettings(next);
return pickAndroidPrefs(next);
});
}
type AndroidPermissionsPanelMode = 'all' | 'accessibility' | 'overlayPermission' | 'overlayConfig';
interface AndroidPermissionsPanelProps {
mode?: AndroidPermissionsPanelMode;
}
export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) {
const { t } = useTranslation();
const [androidOverlay, setAndroidOverlay] = useState<AndroidOverlayStatus | null>(null);
const [androidAccessibility, setAndroidAccessibility] = useState<AndroidAccessibilityStatus | null>(null);
const [androidPrefs, setAndroidPrefs] = useState<Pick<UserPreferences, AndroidPreferenceKey> | null>(null);
const [sizeDraft, setSizeDraft] = useState<number | null>(null);
const sizeDebounceRef = useRef<number | null>(null);
const sizePendingRef = useRef(false);
const refreshAndroid = async () => {
const [overlay, accessibility, settings] = await Promise.all([
getAndroidOverlayStatus(),
getAndroidAccessibilityStatus(),
getSettings(),
]);
let migratedSettings = settings;
if (settings.androidOverlayTrigger === 'keyboard') {
const migratedPrefs = await persistAndroidOverlayPrefs({
androidOverlayTrigger: normalizeAndroidOverlayTrigger(settings.androidOverlayTrigger),
});
migratedSettings = { ...settings, ...migratedPrefs };
}
setAndroidOverlay(overlay);
setAndroidAccessibility(accessibility);
setAndroidPrefs(pickAndroidPrefs(migratedSettings));
};
useEffect(() => {
void refreshAndroid();
const androidId = window.setInterval(refreshAndroid, 3000);
const onFocus = () => { void refreshAndroid(); };
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(androidId);
window.removeEventListener('focus', onFocus);
};
}, []);
useEffect(() => {
if (sizePendingRef.current) return;
if (androidPrefs?.androidOverlaySizeDp != null) {
setSizeDraft(androidPrefs.androidOverlaySizeDp);
}
}, [androidPrefs?.androidOverlaySizeDp]);
useEffect(() => {
return () => {
if (sizeDebounceRef.current) clearTimeout(sizeDebounceRef.current);
};
}, []);
const saveAndroidOverlaySize = async (value: number) => {
const clamped = clampAndroidOverlaySize(value);
try {
const nextPrefs = await persistAndroidOverlayPrefs({ androidOverlaySizeDp: clamped });
setAndroidPrefs((prev) => (prev ? { ...prev, ...nextPrefs } : nextPrefs));
setSizeDraft(clamped);
} catch (error) {
console.error('[android] failed to save overlay size', error);
const settings = await getSettings();
const rolledBack = settings.androidOverlaySizeDp;
const safeDraft = rolledBack != null ? clampAndroidOverlaySize(rolledBack) : null;
setAndroidPrefs((prev) => (prev ? { ...prev, androidOverlaySizeDp: rolledBack } : null));
setSizeDraft(safeDraft);
} finally {
sizePendingRef.current = false;
}
};
const scheduleAndroidOverlaySizeSave = (value: number) => {
if (sizeDebounceRef.current) clearTimeout(sizeDebounceRef.current);
sizeDebounceRef.current = window.setTimeout(() => {
sizeDebounceRef.current = null;
void saveAndroidOverlaySize(value);
}, 200);
};
const flushAndroidOverlaySizeSave = (value: number) => {
const hasDebounce = sizeDebounceRef.current != null;
const hasPending = sizePendingRef.current;
if (!hasDebounce && !hasPending) return;
if (sizeDebounceRef.current) {
clearTimeout(sizeDebounceRef.current);
sizeDebounceRef.current = null;
}
const clamped = clampAndroidOverlaySize(value);
const committed = androidPrefs?.androidOverlaySizeDp;
if (committed != null && clamped === committed) {
sizePendingRef.current = false;
return;
}
void saveAndroidOverlaySize(clamped);
};
const handleAndroidOverlaySizeChange = (value: number) => {
const clamped = clampAndroidOverlaySize(value);
sizePendingRef.current = true;
setSizeDraft(clamped);
scheduleAndroidOverlaySizeSave(clamped);
};
const updateAndroidPref = async <K extends AndroidPreferenceKey>(key: K, value: UserPreferences[K]) => {
if (key !== 'androidOverlaySizeDp' && sizeDebounceRef.current) {
clearTimeout(sizeDebounceRef.current);
sizeDebounceRef.current = null;
}
const patch: Partial<UserPreferences> = {
[key]: key === 'androidOverlayTrigger'
? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger)
: value,
};
if (key !== 'androidOverlaySizeDp') {
const committedSize = androidPrefs?.androidOverlaySizeDp;
const draftSize = sizeDraft;
if (sizePendingRef.current || (draftSize != null && draftSize !== committedSize)) {
patch.androidOverlaySizeDp = clampAndroidOverlaySize(draftSize ?? committedSize ?? 72);
}
sizePendingRef.current = false;
}
try {
const nextPrefs = await persistAndroidOverlayPrefs(patch);
setAndroidPrefs(nextPrefs);
if (patch.androidOverlaySizeDp != null) {
setSizeDraft(nextPrefs.androidOverlaySizeDp);
}
await refreshAndroid();
} catch (error) {
console.error('[android] failed to save overlay pref', error);
await refreshAndroid();
}
};
const showOverlayPermission = mode === 'all' || mode === 'overlayPermission';
const showAccessibility = mode === 'all' || mode === 'accessibility';
const showOverlayConfig = mode === 'all' || mode === 'overlayConfig';
return (
<>
{showOverlayPermission && (
<SettingRow label={t('settings.permissions.androidOverlayLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
{androidOverlay?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
{androidOverlay.message}
</span>
)}
<AndroidOverlayStatusPill status={androidOverlay} />
{androidOverlay?.permission !== 'granted' && (
<Btn variant="ghost" size="sm" onClick={() => { void requestAndroidOverlayPermission().then(refreshAndroid); }}>
{t('settings.permissions.grant')}
</Btn>
)}
</div>
</SettingRow>
)}
{showAccessibility && (
<SettingRow label={t('settings.permissions.androidAccessibilityLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
{androidAccessibility?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
{androidAccessibility.message}
</span>
)}
<AndroidAccessibilityStatusPill status={androidAccessibility} />
{!androidAccessibility?.enabled && (
<Btn variant="ghost" size="sm" onClick={() => { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}>
{t('settings.permissions.openSystem')}
</Btn>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
{t('settings.permissions.androidAccessibilityImpact')}
</span>
</div>
</SettingRow>
)}
{showOverlayConfig && (
<>
<SettingRow label={t('settings.permissions.androidInsertStrategyLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidInsertStrategy ?? 'accessibility'}
onChange={(event) => { void updateAndroidPref('androidInsertStrategy', event.target.value as AndroidInsertStrategy); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="accessibility">{t('settings.permissions.androidInsertStrategy.accessibility')}</option>
<option value="clipboard">{t('settings.permissions.androidInsertStrategy.clipboard')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayTriggerLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayTrigger ?? 'background'}
onChange={(event) => { void updateAndroidPref('androidOverlayTrigger', event.target.value as AndroidOverlayTrigger); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="background">{t('settings.permissions.androidOverlayTrigger.background')}</option>
<option value="keyboard" disabled>{t('settings.permissions.androidOverlayTrigger.keyboard')}</option>
<option value="always">{t('settings.permissions.androidOverlayTrigger.always')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)}
</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayActivationModeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayActivationMode ?? 'tap'}
onChange={(event) => { void updateAndroidPref('androidOverlayActivationMode', event.target.value as AndroidOverlayActivationMode); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="tap">{t('settings.permissions.androidOverlayActivationMode.tap')}</option>
<option value="long_press">{t('settings.permissions.androidOverlayActivationMode.long_press')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayLeftSwipeActionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}
onChange={(event) => { void updateAndroidPref('androidOverlayLeftSwipeAction', event.target.value as AndroidOverlayLeftSwipeAction); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="translation">{t('settings.permissions.androidOverlayLeftSwipeAction.translation')}</option>
<option value="style_pack">{t('settings.permissions.androidOverlayLeftSwipeAction.style_pack')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayCancelSwipeDirectionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}
onChange={(event) => { void updateAndroidPref('androidOverlayCancelSwipeDirection', event.target.value as AndroidOverlayCancelSwipeDirection); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="up">{t('settings.permissions.androidOverlayCancelSwipeDirection.up')}</option>
<option value="down">{t('settings.permissions.androidOverlayCancelSwipeDirection.down')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlaySizeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 180, maxWidth: '100%' }}>
<input
type="range"
min={48}
max={120}
step={4}
value={sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72}
onChange={(event) => {
handleAndroidOverlaySizeChange(Number(event.target.value));
}}
onPointerUp={(event) => {
flushAndroidOverlaySizeSave(Number(event.currentTarget.value));
}}
onTouchEnd={(event) => {
flushAndroidOverlaySizeSave(Number(event.currentTarget.value));
}}
onBlur={(event) => {
flushAndroidOverlaySizeSave(Number(event.currentTarget.value));
}}
style={{ width: 132 }}
/>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: 'right' }}>
{sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t('settings.permissions.androidOverlaySizeHint')}
</span>
</div>
</SettingRow>
</>
)}
</>
);
}
function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) {
const { t } = useTranslation();
if (!status) return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
if (status.permission === 'granted') {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.granted')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.denied')}</Pill>;
}
function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibilityStatus | null }) {
const { t } = useTranslation();
if (!status) return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
if (status.enabled) {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.granted')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.denied')}</Pill>;
}