Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930. Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release. Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests. Co-authored-by: HKLHaoBin <we3q@qq.com>
808 lines
29 KiB
TypeScript
808 lines
29 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { TFunction } from '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 { useLayoutStack, useConservativeLayout } from '../../../src/lib/useMobileLayout';
|
|
import {
|
|
getAndroidAccessibilityStatus,
|
|
getAndroidOverlayStatus,
|
|
getAndroidShizukuStatus,
|
|
openShizukuApp,
|
|
recoverAndroidAccessibility,
|
|
requestAndroidAccessibilityPermission,
|
|
requestAndroidOverlayPermission,
|
|
requestAndroidShizukuPermission,
|
|
} from '../lib/androidIpc';
|
|
import type {
|
|
AndroidAccessibilityStatus,
|
|
AndroidInsertStrategy,
|
|
AndroidOverlayActivationMode,
|
|
AndroidOverlayGestureAction,
|
|
AndroidOverlayStatus,
|
|
AndroidOverlayTrigger,
|
|
AndroidPreferenceKey,
|
|
AndroidShizukuStatus,
|
|
} 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,
|
|
androidOverlayGestureActions: settings.androidOverlayGestureActions,
|
|
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'
|
|
| 'permissionsOnly'
|
|
| 'accessibility'
|
|
| 'overlayPermission'
|
|
| 'overlayConfig';
|
|
|
|
interface AndroidPermissionsPanelProps {
|
|
mode?: AndroidPermissionsPanelMode;
|
|
}
|
|
|
|
export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) {
|
|
const { t } = useTranslation();
|
|
const baseLayoutStack = useLayoutStack();
|
|
const conservative = useConservativeLayout();
|
|
const layoutStack = baseLayoutStack || conservative;
|
|
const [androidOverlay, setAndroidOverlay] = useState<AndroidOverlayStatus | null>(null);
|
|
const [androidAccessibility, setAndroidAccessibility] =
|
|
useState<AndroidAccessibilityStatus | null>(null);
|
|
const [androidShizuku, setAndroidShizuku] = useState<AndroidShizukuStatus | null>(null);
|
|
const [shizukuRecoveryMessageKey, setShizukuRecoveryMessageKey] = useState<string | null>(null);
|
|
const [shizukuActionMessageKey, setShizukuActionMessageKey] = useState<string | null>(null);
|
|
const [shizukuRecoveryPending, setShizukuRecoveryPending] = useState(false);
|
|
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 [overlayResult, accessibilityResult, shizukuResult, settingsResult] =
|
|
await Promise.allSettled([
|
|
getAndroidOverlayStatus(),
|
|
getAndroidAccessibilityStatus(),
|
|
getAndroidShizukuStatus(),
|
|
getSettings(),
|
|
]);
|
|
if (overlayResult.status === 'fulfilled') {
|
|
setAndroidOverlay(overlayResult.value);
|
|
}
|
|
if (accessibilityResult.status === 'fulfilled') {
|
|
setAndroidAccessibility(accessibilityResult.value);
|
|
}
|
|
if (shizukuResult.status === 'fulfilled') {
|
|
const shizuku = shizukuResult.value;
|
|
setAndroidShizuku((prev) => {
|
|
const operational = shizuku.accessibility.operational;
|
|
if (operational && !prev?.accessibility.operational) {
|
|
setShizukuRecoveryMessageKey(null);
|
|
}
|
|
if (shizuku.lastPermissionMessageKey) {
|
|
setShizukuActionMessageKey(null);
|
|
}
|
|
return shizuku;
|
|
});
|
|
}
|
|
if (settingsResult.status === 'fulfilled') {
|
|
let migratedSettings = settingsResult.value;
|
|
if (migratedSettings.androidOverlayTrigger === 'keyboard') {
|
|
const migratedPrefs = await persistAndroidOverlayPrefs({
|
|
androidOverlayTrigger: normalizeAndroidOverlayTrigger(
|
|
migratedSettings.androidOverlayTrigger,
|
|
),
|
|
});
|
|
migratedSettings = { ...migratedSettings, ...migratedPrefs };
|
|
}
|
|
setAndroidPrefs(pickAndroidPrefs(migratedSettings));
|
|
}
|
|
};
|
|
|
|
const handleRecoverAccessibility = async () => {
|
|
if (shizukuRecoveryPending) return;
|
|
const confirmed = window.confirm(t('settings.permissions.androidShizukuRecoverConfirm'));
|
|
if (!confirmed) return;
|
|
setShizukuActionMessageKey(null);
|
|
setShizukuRecoveryPending(true);
|
|
try {
|
|
const result = await recoverAndroidAccessibility(true);
|
|
setShizukuRecoveryMessageKey(result.messageKey);
|
|
await refreshAndroid();
|
|
} finally {
|
|
setShizukuRecoveryPending(false);
|
|
}
|
|
};
|
|
|
|
const shizukuDisplayMessageKey =
|
|
androidShizuku?.lastPermissionMessageKey ??
|
|
shizukuRecoveryMessageKey ??
|
|
shizukuActionMessageKey ??
|
|
androidShizuku?.messageKey ??
|
|
null;
|
|
|
|
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 === 'permissionsOnly' || mode === 'overlayPermission';
|
|
const showAccessibility =
|
|
mode === 'all' || mode === 'permissionsOnly' || mode === 'accessibility';
|
|
const showOverlayConfig = mode === 'all' || mode === 'overlayConfig';
|
|
|
|
const rowJustify = layoutStack ? 'flex-start' : 'flex-end';
|
|
const colAlign = layoutStack ? 'stretch' : 'flex-end';
|
|
const hintAlign = layoutStack ? 'left' : 'right';
|
|
const hintMaxWidth = layoutStack ? undefined : 300;
|
|
const hintMaxWidthSm = layoutStack ? undefined : 220;
|
|
const selectStyle = layoutStack
|
|
? { width: '100%', maxWidth: '100%' as const }
|
|
: { minWidth: 180, maxWidth: '100%' as const };
|
|
const actionRowStyle: CSSProperties = {
|
|
display: 'flex',
|
|
flexDirection: conservative ? 'column' : 'row',
|
|
gap: 8,
|
|
alignItems: conservative ? 'flex-start' : layoutStack ? 'flex-start' : 'center',
|
|
justifyContent: rowJustify,
|
|
width: '100%',
|
|
flexWrap: conservative ? 'nowrap' : 'wrap',
|
|
minWidth: 0,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{showOverlayPermission && (
|
|
<SettingRow label={t('settings.permissions.androidOverlayLabel')}>
|
|
<div className="ol-flex-row" style={actionRowStyle}>
|
|
{androidOverlay?.message && (
|
|
<span
|
|
style={{
|
|
fontSize: 11.5,
|
|
color: 'var(--ol-ink-4)',
|
|
maxWidth: hintMaxWidthSm,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{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: colAlign,
|
|
width: '100%',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<div className="ol-flex-row" style={actionRowStyle}>
|
|
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && (
|
|
<span
|
|
style={{
|
|
fontSize: 11.5,
|
|
color: 'var(--ol-ink-4)',
|
|
maxWidth: hintMaxWidthSm,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{resolveAccessibilityMessage(t, androidAccessibility?.messageKey)}
|
|
</span>
|
|
)}
|
|
<AndroidAccessibilityStatusPill status={androidAccessibility} />
|
|
{(!androidAccessibility?.enabled || androidAccessibility?.operational === false) && (
|
|
<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: hintMaxWidth,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{t('settings.permissions.androidAccessibilityImpact')}
|
|
</span>
|
|
</div>
|
|
</SettingRow>
|
|
)}
|
|
{showAccessibility && (
|
|
<SettingRow label={t('settings.permissions.androidShizukuLabel')}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
alignItems: colAlign,
|
|
width: '100%',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<div className="ol-flex-row" style={actionRowStyle}>
|
|
{resolveShizukuMessage(t, shizukuDisplayMessageKey) && (
|
|
<span
|
|
style={{
|
|
fontSize: 11.5,
|
|
color: 'var(--ol-ink-4)',
|
|
maxWidth: hintMaxWidthSm,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{resolveShizukuMessage(t, shizukuDisplayMessageKey)}
|
|
</span>
|
|
)}
|
|
<AndroidShizukuStatusPill status={androidShizuku} />
|
|
{(androidShizuku?.state === 'notInstalled' ||
|
|
androidShizuku?.state === 'notRunning' ||
|
|
androidShizuku?.state === 'binderDead') && (
|
|
<Btn
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShizukuRecoveryMessageKey(null);
|
|
setShizukuActionMessageKey(null);
|
|
void openShizukuApp().then((result) => {
|
|
if (!result.launched) {
|
|
setShizukuActionMessageKey(result.messageKey);
|
|
}
|
|
return refreshAndroid();
|
|
});
|
|
}}
|
|
>
|
|
{t('settings.permissions.androidShizukuOpenApp')}
|
|
</Btn>
|
|
)}
|
|
{androidShizuku?.state === 'notAuthorized' && (
|
|
<Btn
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShizukuRecoveryMessageKey(null);
|
|
setShizukuActionMessageKey(null);
|
|
void requestAndroidShizukuPermission().then((result) => {
|
|
if (!result.launched) {
|
|
setShizukuActionMessageKey(result.messageKey);
|
|
}
|
|
return refreshAndroid();
|
|
});
|
|
}}
|
|
>
|
|
{t('settings.permissions.androidShizukuRequestPermission')}
|
|
</Btn>
|
|
)}
|
|
{androidShizuku?.state === 'authorized' &&
|
|
!androidShizuku.accessibility.operational && (
|
|
<Btn
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={shizukuRecoveryPending}
|
|
onClick={() => {
|
|
void handleRecoverAccessibility();
|
|
}}
|
|
>
|
|
{shizukuRecoveryPending
|
|
? t('settings.permissions.checking')
|
|
: t('settings.permissions.androidShizukuRecover')}
|
|
</Btn>
|
|
)}
|
|
{shizukuRecoveryMessageKey &&
|
|
androidShizuku?.state === 'authorized' &&
|
|
!androidShizuku.accessibility.operational &&
|
|
(shizukuRecoveryMessageKey === 'partial_rollback' ||
|
|
shizukuRecoveryMessageKey === 'manual_required' ||
|
|
shizukuRecoveryMessageKey === 'oem_rollback' ||
|
|
shizukuRecoveryMessageKey === 'concurrent_change') && (
|
|
<Btn
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
void requestAndroidAccessibilityPermission().then(refreshAndroid);
|
|
}}
|
|
>
|
|
{t('settings.permissions.openSystem')}
|
|
</Btn>
|
|
)}
|
|
</div>
|
|
{androidShizuku?.state === 'authorized' && (
|
|
<span
|
|
style={{
|
|
fontSize: 11,
|
|
color: 'var(--ol-ink-4)',
|
|
maxWidth: hintMaxWidth,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{androidShizuku.accessibility.operational
|
|
? t('settings.permissions.androidShizukuAccessibilityOperational')
|
|
: t('settings.permissions.androidShizukuAccessibilityRegistered', {
|
|
registered: androidShizuku.accessibility.registered
|
|
? t('settings.permissions.androidShizukuYes')
|
|
: t('settings.permissions.androidShizukuNo'),
|
|
operational: androidShizuku.accessibility.operational
|
|
? t('settings.permissions.androidShizukuYes')
|
|
: t('settings.permissions.androidShizukuNo'),
|
|
})}
|
|
</span>
|
|
)}
|
|
<span
|
|
style={{
|
|
fontSize: 11,
|
|
color: 'var(--ol-ink-4)',
|
|
maxWidth: hintMaxWidth,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{t('settings.permissions.androidShizukuHint')}
|
|
</span>
|
|
</div>
|
|
</SettingRow>
|
|
)}
|
|
{showOverlayConfig && (
|
|
<>
|
|
<SettingRow label={t('settings.permissions.androidInsertStrategyLabel')}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
alignItems: colAlign,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<select
|
|
value={androidPrefs?.androidInsertStrategy ?? 'accessibility'}
|
|
onChange={(event) => {
|
|
void updateAndroidPref(
|
|
'androidInsertStrategy',
|
|
event.target.value as AndroidInsertStrategy,
|
|
);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<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: hintAlign }}>
|
|
{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: colAlign,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<select
|
|
value={androidPrefs?.androidOverlayTrigger ?? 'background'}
|
|
onChange={(event) => {
|
|
void updateAndroidPref(
|
|
'androidOverlayTrigger',
|
|
event.target.value as AndroidOverlayTrigger,
|
|
);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<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: hintAlign }}>
|
|
{t(
|
|
`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`,
|
|
)}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
|
{t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
|
|
</span>
|
|
</div>
|
|
</SettingRow>
|
|
<SettingRow label={t('settings.permissions.androidOverlayActivationModeLabel')}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
alignItems: colAlign,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<select
|
|
value={androidPrefs?.androidOverlayActivationMode ?? 'tap'}
|
|
onChange={(event) => {
|
|
void updateAndroidPref(
|
|
'androidOverlayActivationMode',
|
|
event.target.value as AndroidOverlayActivationMode,
|
|
);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<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: hintAlign }}>
|
|
{t(
|
|
`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t(
|
|
'settings.permissions.androidOverlayGestureActionsLabel',
|
|
'Overlay swipe actions',
|
|
)}
|
|
desc={t(
|
|
'settings.permissions.androidOverlayGestureActionsDesc',
|
|
'These actions apply while recording. A normal tap finishes ordinary dictation; a Quick note swipe keeps the audio permanently.',
|
|
)}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
|
gap: 8,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{(['up', 'down', 'left', 'right'] as const).map((direction) => (
|
|
<label
|
|
key={direction}
|
|
style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}
|
|
>
|
|
<span style={{ fontSize: 11, color: 'var(--ol-ink-3)' }}>
|
|
{t(
|
|
`settings.permissions.androidOverlayGestureDirection.${direction}`,
|
|
direction,
|
|
)}
|
|
</span>
|
|
<select
|
|
value={androidPrefs?.androidOverlayGestureActions?.[direction] ?? 'none'}
|
|
onChange={(event) => {
|
|
const actions = androidPrefs?.androidOverlayGestureActions ?? {
|
|
up: 'cancel',
|
|
down: 'none',
|
|
left: 'translation',
|
|
right: 'qa',
|
|
};
|
|
void updateAndroidPref('androidOverlayGestureActions', {
|
|
...actions,
|
|
[direction]: event.target.value as AndroidOverlayGestureAction,
|
|
});
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
{(
|
|
['none', 'quick_note', 'translation', 'style_pack', 'cancel', 'qa'] as const
|
|
).map((action) => (
|
|
<option key={action} value={action}>
|
|
{t(
|
|
`settings.permissions.androidOverlayGestureAction.${action}`,
|
|
action,
|
|
)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</SettingRow>
|
|
<SettingRow label={t('settings.permissions.androidOverlaySizeLabel')}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 6,
|
|
alignItems: colAlign,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
width: '100%',
|
|
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={{ flex: 1, minWidth: 0 }}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 12,
|
|
color: 'var(--ol-ink-3)',
|
|
minWidth: 42,
|
|
textAlign: hintAlign,
|
|
}}
|
|
>
|
|
{sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
|
|
</span>
|
|
</div>
|
|
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
|
|
{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 && status.operational === false) {
|
|
return <Pill tone="outline">{t('settings.permissions.androidAccessibilityGrantedStale')}</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>;
|
|
}
|
|
|
|
function resolveShizukuMessage(t: TFunction, key: string | null | undefined): string {
|
|
if (!key) return '';
|
|
return t(`settings.permissions.androidShizukuMessages.${key}`, { defaultValue: key });
|
|
}
|
|
|
|
function resolveAccessibilityMessage(t: TFunction, key: string | null | undefined): string {
|
|
if (!key) return '';
|
|
return t(`settings.permissions.androidAccessibilityMessages.${key}`, { defaultValue: '' });
|
|
}
|
|
|
|
function AndroidShizukuStatusPill({ status }: { status: AndroidShizukuStatus | null }) {
|
|
const { t } = useTranslation();
|
|
if (!status) return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
|
|
const labelKey = `settings.permissions.androidShizukuState.${status.state}` as const;
|
|
if (status.state === 'authorized') {
|
|
return (
|
|
<Pill tone="ok">
|
|
<Icon name="check" size={11} />
|
|
{t(labelKey)}
|
|
</Pill>
|
|
);
|
|
}
|
|
if (status.state === 'notAndroid') {
|
|
return <Pill tone="default">{t(labelKey)}</Pill>;
|
|
}
|
|
return <Pill tone="outline">{t(labelKey)}</Pill>;
|
|
}
|