Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e91a0d09 | |||
| cb59af6b6c | |||
| 6b9a39fe4a | |||
| a01abbb1bf | |||
| bdea95511d |
@@ -12,6 +12,12 @@ Session-token authentication for the signaling server and product client.
|
|||||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
||||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||||
|
|
||||||
|
## Client logout
|
||||||
|
|
||||||
|
- Desktop: title-bar menu **Logout** (`UserLogoutService`).
|
||||||
|
- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints.
|
||||||
|
- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`.
|
||||||
|
|
||||||
## Login / register response
|
## Login / register response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -115,6 +121,8 @@ A per-install **provision secret** enables silent account creation on newly adde
|
|||||||
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||||
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
||||||
|
|
||||||
|
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
|
||||||
|
|
||||||
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
||||||
|
|
||||||
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
|
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sh
|
|||||||
|
|
||||||
This produces, for every density (`mdpi … xxxhdpi`):
|
This produces, for every density (`mdpi … xxxhdpi`):
|
||||||
|
|
||||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
|
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc inset to the adaptive-icon safe zone so circular masks do not clip the cat face).
|
||||||
- `mipmap-*/ic_launcher_foreground.png` — full-bleed adaptive foreground; the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` (the stock `drawable-v24/ic_launcher_foreground.xml` vector is removed).
|
- `mipmap-*/ic_launcher_foreground.png` — adaptive foreground centred at **66/108** of the 108dp canvas (Android safe zone); the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` with `@color/ic_launcher_background` brand purple behind it.
|
||||||
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
|
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
|
||||||
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred on a purple field for the launch splash.
|
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred at **32%** of the shorter splash edge on a purple field (down from 40% so the cat face is not cropped on launch).
|
||||||
|
|
||||||
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
|
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
|
||||||
|
|
||||||
@@ -134,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
|||||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||||
|
|
||||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells.
|
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins.
|
||||||
|
|
||||||
|
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
||||||
|
|
||||||
### iOS (APNs)
|
### iOS (APNs)
|
||||||
|
|
||||||
@@ -199,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
|
|
||||||
## Safe area (Android)
|
## Safe area (Android)
|
||||||
|
|
||||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
|
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
|
||||||
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
||||||
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
|
- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
|
||||||
|
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
|
||||||
|
|
||||||
## Self-hosted HTTPS signal servers (Android)
|
## Self-hosted HTTPS signal servers (Android)
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ Network security configs:
|
|||||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
||||||
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
||||||
|
- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out).
|
||||||
|
|
||||||
## Phase 3 completion notes
|
## Phase 3 completion notes
|
||||||
|
|
||||||
|
|||||||
76
e2e/helpers/settings-modal.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
export async function openSettingsModal(page: Page, settingsPage = 'general'): Promise<void> {
|
||||||
|
await page.evaluate((targetPage) => {
|
||||||
|
interface SettingsModalServiceHandle {
|
||||||
|
open: (page: string) => void;
|
||||||
|
}
|
||||||
|
interface SettingsModalComponentHandle {
|
||||||
|
mobilePage?: { set: (page: 'menu' | 'detail') => void };
|
||||||
|
animating?: { set: (value: boolean) => void };
|
||||||
|
navigate?: (page: string) => void;
|
||||||
|
}
|
||||||
|
interface AppComponentHandle {
|
||||||
|
settingsModal?: SettingsModalServiceHandle;
|
||||||
|
}
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => AppComponentHandle & SettingsModalComponentHandle;
|
||||||
|
applyChanges?: (component: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
||||||
|
const appRoot = document.querySelector('app-root');
|
||||||
|
const settingsHost = document.querySelector('app-settings-modal');
|
||||||
|
const appComponent = appRoot && debugApi?.getComponent(appRoot);
|
||||||
|
const settingsComponent = settingsHost && debugApi?.getComponent(settingsHost);
|
||||||
|
|
||||||
|
if (!appComponent?.settingsModal?.open) {
|
||||||
|
throw new Error('Angular debug API could not open settings modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
appComponent.settingsModal.open(targetPage);
|
||||||
|
settingsComponent?.mobilePage?.set('menu');
|
||||||
|
settingsComponent?.animating?.set(true);
|
||||||
|
debugApi?.applyChanges?.(appComponent);
|
||||||
|
debugApi?.applyChanges?.(settingsComponent);
|
||||||
|
}, settingsPage);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId('settings-logout-button')).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettingsDetailPage(page: Page, settingsPage: string): Promise<void> {
|
||||||
|
await openSettingsModal(page, settingsPage);
|
||||||
|
|
||||||
|
await page.evaluate((targetPage) => {
|
||||||
|
interface SettingsModalComponentHandle {
|
||||||
|
navigate?: (page: string) => void;
|
||||||
|
animating?: { set: (value: boolean) => void };
|
||||||
|
}
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => SettingsModalComponentHandle;
|
||||||
|
applyChanges?: (component: SettingsModalComponentHandle) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-settings-modal');
|
||||||
|
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
|
||||||
|
const component = host && debugApi?.getComponent(host);
|
||||||
|
|
||||||
|
if (!component?.navigate) {
|
||||||
|
throw new Error('Angular debug API could not navigate settings modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
component.navigate(targetPage);
|
||||||
|
component.animating?.set(true);
|
||||||
|
debugApi?.applyChanges?.(component);
|
||||||
|
}, settingsPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettingsDataPage(page: Page): Promise<void> {
|
||||||
|
await openSettingsDetailPage(page, 'data');
|
||||||
|
await expect(page.locator('app-data-settings')).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MOBILE_VIEWPORT };
|
||||||
88
e2e/tests/auth/offline-signal-server-no-login-loop.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import { readSignalServerCredentialFromPage, SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY } from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
|
||||||
|
const PRIMARY_ENDPOINT_ID = 'e2e-offline-login-primary';
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
|
||||||
|
test.describe('Offline signal server navigation', () => {
|
||||||
|
test('does not redirect to authorize login after a foreign server goes offline', async ({ createClient }) => {
|
||||||
|
const primaryServer = await startTestServer();
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
const suffix = `offline_login_${Date.now()}`;
|
||||||
|
const username = `user_${suffix}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installTestServerEndpoints(client.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await test.step('Register and provision a secondary signal server', async () => {
|
||||||
|
const register = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(username, 'Offline Login User', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(client.page);
|
||||||
|
|
||||||
|
await openSettingsFromMenu(client.page);
|
||||||
|
await client.page.getByRole('button', { name: 'Network' }).click();
|
||||||
|
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
|
||||||
|
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
|
||||||
|
await client.page.getByTestId('add-signal-server-button').click();
|
||||||
|
|
||||||
|
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
await client.page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Offline secondary endpoints do not trigger authorize login', async () => {
|
||||||
|
await secondaryServer.stop();
|
||||||
|
|
||||||
|
await client.page.evaluate(({ storageKey, url }) => {
|
||||||
|
const normalizedUrl = url.trim().replace(/\/+$/, '');
|
||||||
|
const credentialStore = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, unknown>;
|
||||||
|
const nextCredentialStore = Object.fromEntries(
|
||||||
|
Object.entries(credentialStore).filter(([key]) => key !== normalizedUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(nextCredentialStore));
|
||||||
|
|
||||||
|
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as {
|
||||||
|
url: string;
|
||||||
|
status: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
localStorage.setItem('metoyou_server_endpoints', JSON.stringify(endpoints.map((endpoint) =>
|
||||||
|
endpoint.url.trim().replace(/\/+$/, '') === normalizedUrl
|
||||||
|
? { ...endpoint, status: 'offline' }
|
||||||
|
: endpoint
|
||||||
|
)));
|
||||||
|
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: secondaryServer.url });
|
||||||
|
|
||||||
|
await client.page.goto('/dashboard', { waitUntil: 'commit', timeout: 10_000 });
|
||||||
|
await expect(client.page).not.toHaveURL(/\/login/);
|
||||||
|
await expect(client.page.url()).not.toMatch(/mode=authorize/);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await primaryServer.stop();
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,15 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
import { test, expect } from '../../fixtures/base';
|
import { test, expect } from '../../fixtures/base';
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
REQUIRED_SPLASH_FILES
|
REQUIRED_SPLASH_FILES,
|
||||||
|
SPLASH_ICON_RATIO
|
||||||
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
|
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,4 +106,16 @@ test.describe('Android brand app icon', () => {
|
|||||||
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
|
||||||
|
const foreground = 'mipmap-xxxhdpi/ic_launcher_foreground.png';
|
||||||
|
const { data, info } = await sharp(join(RES_DIR, foreground)).ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
|
||||||
|
|
||||||
|
expect(data[topCenterOffset + 3]).toBeLessThan(32);
|
||||||
|
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
|
||||||
|
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
e2e/tests/mobile/mobile-settings-logout.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from '../../fixtures/multi-client';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
|
import { MOBILE_VIEWPORT, openSettingsModal } from '../../helpers/settings-modal';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
|
||||||
|
test.describe('Mobile settings logout', () => {
|
||||||
|
test('exposes logout in the settings menu on mobile viewports', async ({ createClient }) => {
|
||||||
|
const { page } = await createClient();
|
||||||
|
const suffix = `mobile_logout_${Date.now()}`;
|
||||||
|
|
||||||
|
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||||
|
|
||||||
|
const register = new RegisterPage(page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(`user_${suffix}`, 'Mobile Logout User', 'TestPass123!');
|
||||||
|
await expectDashboardReady(page);
|
||||||
|
|
||||||
|
await openSettingsModal(page);
|
||||||
|
await page.getByTestId('settings-logout-button').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
await expect(page.locator('#login-username')).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
@@ -2,21 +2,31 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -262,12 +262,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||||
|
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||||
"restartApp": "Restart app"
|
"restartApp": "Restart app"
|
||||||
},
|
},
|
||||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||||
"currentFolder": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"title": "Current data folder",
|
||||||
"resolving": "Resolving data folder..."
|
"resolving": "Resolving data folder...",
|
||||||
|
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||||
},
|
},
|
||||||
"openFolder": "Open folder",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -287,6 +289,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"description": "Remove local app data from this device and recreate an empty database.",
|
||||||
|
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||||
"button": "Erase user data",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||||
@@ -301,6 +304,7 @@
|
|||||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||||
|
"erasedMobile": "Local data erased. You have been signed out.",
|
||||||
"operationFailed": "Data operation failed."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1317,12 +1317,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||||
|
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||||
"restartApp": "Restart app"
|
"restartApp": "Restart app"
|
||||||
},
|
},
|
||||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||||
"currentFolder": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"title": "Current data folder",
|
||||||
"resolving": "Resolving data folder..."
|
"resolving": "Resolving data folder...",
|
||||||
|
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||||
},
|
},
|
||||||
"openFolder": "Open folder",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -1342,6 +1344,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"description": "Remove local app data from this device and recreate an empty database.",
|
||||||
|
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||||
"button": "Erase user data",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||||
@@ -1356,6 +1359,7 @@
|
|||||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||||
|
"erasedMobile": "Local data erased. You have been signed out.",
|
||||||
"operationFailed": "Data operation failed."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="appRoot"
|
appThemeNode="appRoot"
|
||||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||||
|
[class.metoyou-safe-area-shell]="isMobile()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||||
|
|||||||
@@ -43,4 +43,13 @@ describe('AuthTokenStoreService', () => {
|
|||||||
|
|
||||||
expect(service.getToken('http://localhost:3001')).toBeNull();
|
expect(service.getToken('http://localhost:3001')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears every stored token', () => {
|
||||||
|
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
|
||||||
|
service.setToken('http://localhost:3002', 'token-def', Date.now() + 60_000);
|
||||||
|
|
||||||
|
service.clearAllTokens();
|
||||||
|
|
||||||
|
expect(service.hasAnyValidToken()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export class AuthTokenStoreService {
|
|||||||
this.writeStore(nextStore);
|
this.writeStore(nextStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAllTokens(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
hasAnyValidToken(): boolean {
|
hasAnyValidToken(): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
||||||
|
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
|
||||||
|
import { shouldNavigateToAuthorizeSignalServer } from '../../domain/logic/signal-server-authorize.rules';
|
||||||
import { SignalServerAuthService } from './signal-server-auth.service';
|
import { SignalServerAuthService } from './signal-server-auth.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -25,25 +27,43 @@ export class SignalServerAuthorizeService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (result.kind === 'existing' || result.kind === 'provisioned') {
|
if (result.kind === 'existing' || result.kind === 'provisioned') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === 'collision') {
|
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
|
||||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.kind === 'skipped' && result.reason === 'no-provision-secret') {
|
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
|
||||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveEndpointStatusForAuthorize(serverUrl: string) {
|
||||||
|
const endpoint = this.serverDirectory.findServerByUrl(serverUrl);
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEndpointOnlineForConnection(endpoint.status) || endpoint.status === 'offline' || endpoint.status === 'incompatible') {
|
||||||
|
return endpoint.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.serverDirectory.testServer(endpoint.id);
|
||||||
|
|
||||||
|
return this.serverDirectory.servers().find((candidate) => candidate.id === endpoint.id)?.status ?? endpoint.status;
|
||||||
|
}
|
||||||
|
|
||||||
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
||||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||||
name: this.buildEndpointName(serverUrl),
|
name: this.buildEndpointName(serverUrl),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||||
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
|
import { UserLogoutService } from './user-logout.service';
|
||||||
|
|
||||||
|
describe('UserLogoutService', () => {
|
||||||
|
let webrtc: { disconnect: ReturnType<typeof vi.fn> };
|
||||||
|
let store: { dispatch: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
let service: UserLogoutService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: vi.fn(() => null),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
key: vi.fn(() => null),
|
||||||
|
length: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
webrtc = { disconnect: vi.fn() };
|
||||||
|
store = { dispatch: vi.fn() };
|
||||||
|
router = { navigate: vi.fn(() => Promise.resolve(true)) };
|
||||||
|
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
UserLogoutService,
|
||||||
|
{ provide: RealtimeSessionFacade, useValue: webrtc },
|
||||||
|
{ provide: Store, useValue: store },
|
||||||
|
{ provide: Router, useValue: router }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = runInInjectionContext(injector, () => injector.get(UserLogoutService));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects, clears persisted user scope, resets store slices, and navigates to login', () => {
|
||||||
|
service.logout();
|
||||||
|
|
||||||
|
expect(webrtc.disconnect).toHaveBeenCalledOnce();
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(MessagesActions.clearMessages());
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(RoomsActions.resetRoomsState());
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(UsersActions.resetUsersState());
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can reset client state without navigating away', () => {
|
||||||
|
service.logout({ navigate: false });
|
||||||
|
|
||||||
|
expect(router.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { clearStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||||
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||||
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserLogoutService {
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
logout(options?: { navigate?: boolean }): void {
|
||||||
|
this.webrtc.disconnect();
|
||||||
|
clearStoredCurrentUserId();
|
||||||
|
this.store.dispatch(MessagesActions.clearMessages());
|
||||||
|
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||||
|
this.store.dispatch(UsersActions.resetUsersState());
|
||||||
|
|
||||||
|
if (options?.navigate !== false) {
|
||||||
|
void this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { shouldNavigateToAuthorizeSignalServer } from './signal-server-authorize.rules';
|
||||||
|
|
||||||
|
describe('signal-server-authorize rules', () => {
|
||||||
|
it('does not navigate to authorize when the signal server is offline', () => {
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('offline', {
|
||||||
|
kind: 'skipped',
|
||||||
|
reason: 'no-provision-secret'
|
||||||
|
})).toBe(false);
|
||||||
|
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('offline', {
|
||||||
|
kind: 'collision',
|
||||||
|
error: new Error('collision') as never
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to authorize on online servers that need manual sign-in', () => {
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('online', {
|
||||||
|
kind: 'skipped',
|
||||||
|
reason: 'no-provision-secret'
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('online', {
|
||||||
|
kind: 'collision',
|
||||||
|
error: new Error('collision') as never
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not navigate for unknown endpoint status or non-authorize provision outcomes', () => {
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('unknown', {
|
||||||
|
kind: 'skipped',
|
||||||
|
reason: 'no-provision-secret'
|
||||||
|
})).toBe(false);
|
||||||
|
|
||||||
|
expect(shouldNavigateToAuthorizeSignalServer('online', {
|
||||||
|
kind: 'skipped',
|
||||||
|
reason: 'no-home-user'
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { EnsureProvisionedResult } from '../../application/services/signal-server-auth.service';
|
||||||
|
import type { ServerEndpointStatus } from '../../../server-directory/domain/models/server-directory.model';
|
||||||
|
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
|
||||||
|
|
||||||
|
export function shouldNavigateToAuthorizeSignalServer(
|
||||||
|
endpointStatus: ServerEndpointStatus | undefined | null,
|
||||||
|
provisionResult: EnsureProvisionedResult
|
||||||
|
): boolean {
|
||||||
|
if (!isEndpointOnlineForConnection(endpointStatus)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provisionResult.kind === 'collision') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return provisionResult.kind === 'skipped' && provisionResult.reason === 'no-provision-secret';
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './application/services/auth-token-store.service';
|
export * from './application/services/auth-token-store.service';
|
||||||
|
export * from './application/services/user-logout.service';
|
||||||
export * from './application/services/signal-server-auth.service';
|
export * from './application/services/signal-server-auth.service';
|
||||||
export * from './application/services/signal-server-authorize.service';
|
export * from './application/services/signal-server-authorize.service';
|
||||||
export * from './application/services/signal-server-credential-store.service';
|
export * from './application/services/signal-server-credential-store.service';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type RoomSignalSource,
|
type RoomSignalSource,
|
||||||
type RoomSignalSourceInput
|
type RoomSignalSourceInput
|
||||||
} from '../../domain/logic/room-signal-source.logic';
|
} from '../../domain/logic/room-signal-source.logic';
|
||||||
|
import { isEndpointOnlineForConnection } from '../../domain/logic/server-endpoint-connectivity.rules';
|
||||||
import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
|
import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
|
||||||
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
|
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
|
||||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||||
@@ -110,7 +111,7 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||||
|
|
||||||
if (!clientVersion) {
|
if (!clientVersion && isEndpointOnlineForConnection(endpoint.status)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
||||||
|
|
||||||
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
|
return isEndpointOnlineForConnection(refreshedEndpoint?.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveRoomEndpoint(
|
resolveRoomEndpoint(
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { isEndpointOnlineForConnection } from './server-endpoint-connectivity.rules';
|
||||||
|
|
||||||
|
describe('server-endpoint-connectivity rules', () => {
|
||||||
|
it('treats only online endpoints as reachable for connection', () => {
|
||||||
|
expect(isEndpointOnlineForConnection('online')).toBe(true);
|
||||||
|
expect(isEndpointOnlineForConnection('offline')).toBe(false);
|
||||||
|
expect(isEndpointOnlineForConnection('checking')).toBe(false);
|
||||||
|
expect(isEndpointOnlineForConnection('unknown')).toBe(false);
|
||||||
|
expect(isEndpointOnlineForConnection('incompatible')).toBe(false);
|
||||||
|
expect(isEndpointOnlineForConnection(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ServerEndpointStatus } from '../models/server-directory.model';
|
||||||
|
|
||||||
|
/** True only when health checks report the endpoint is reachable. */
|
||||||
|
export function isEndpointOnlineForConnection(
|
||||||
|
status: ServerEndpointStatus | undefined | null
|
||||||
|
): boolean {
|
||||||
|
return status === 'online';
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
supportsDesktopDataFolderActions,
|
||||||
|
supportsLocalDataManagement,
|
||||||
|
supportsMobileLocalDataErase
|
||||||
|
} from './data-settings-capability.rules';
|
||||||
|
|
||||||
|
describe('data settings capability rules', () => {
|
||||||
|
it('enables local data management on Electron and Capacitor only', () => {
|
||||||
|
expect(supportsLocalDataManagement({ isElectron: true, isCapacitor: false })).toBe(true);
|
||||||
|
expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: true })).toBe(true);
|
||||||
|
expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits folder, export, and import actions to Electron', () => {
|
||||||
|
expect(supportsDesktopDataFolderActions({ isElectron: true, isCapacitor: false })).toBe(true);
|
||||||
|
expect(supportsDesktopDataFolderActions({ isElectron: false, isCapacitor: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows erase on Capacitor shells', () => {
|
||||||
|
expect(supportsMobileLocalDataErase({ isElectron: false, isCapacitor: true })).toBe(true);
|
||||||
|
expect(supportsMobileLocalDataErase({ isElectron: true, isCapacitor: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface DataSettingsPlatform {
|
||||||
|
isElectron: boolean;
|
||||||
|
isCapacitor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsLocalDataManagement(platform: DataSettingsPlatform): boolean {
|
||||||
|
return platform.isElectron || platform.isCapacitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsDesktopDataFolderActions(platform: DataSettingsPlatform): boolean {
|
||||||
|
return platform.isElectron;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsMobileLocalDataErase(platform: DataSettingsPlatform): boolean {
|
||||||
|
return platform.isCapacitor;
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
{{ 'settings.data.localData.description' | translate }}
|
@if (supportsMobileLocalDataErase) {
|
||||||
|
{{ 'settings.data.localData.descriptionMobile' | translate }}
|
||||||
|
} @else {
|
||||||
|
{{ 'settings.data.localData.description' | translate }}
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,17 +29,17 @@
|
|||||||
name="lucideRefreshCw"
|
name="lucideRefreshCw"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
Restart app
|
{{ 'settings.data.localData.restartApp' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (!isElectron) {
|
@if (!supportsLocalDataManagement) {
|
||||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||||
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else if (supportsDesktopDataFolderActions) {
|
||||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
||||||
@@ -108,6 +112,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="data-settings-erase-button"
|
||||||
(click)="eraseData()"
|
(click)="eraseData()"
|
||||||
[disabled]="busyAction() !== null"
|
[disabled]="busyAction() !== null"
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@@ -119,17 +124,48 @@
|
|||||||
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
} @else if (supportsMobileLocalDataErase) {
|
||||||
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
||||||
|
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">{{ 'settings.data.currentFolder.descriptionMobile' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (statusMessage()) {
|
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
|
||||||
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
<div>
|
||||||
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
|
||||||
</section>
|
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.descriptionMobile' | translate }}</p>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
@if (errorMessage()) {
|
<button
|
||||||
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
type="button"
|
||||||
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
data-testid="data-settings-erase-button"
|
||||||
</section>
|
(click)="eraseData()"
|
||||||
}
|
[disabled]="busyAction() !== null"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (statusMessage()) {
|
||||||
|
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
||||||
|
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||||
|
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ import {
|
|||||||
lucideUpload
|
lucideUpload
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { CapacitorAttachmentFileStore } from '../../../../domains/attachment/infrastructure/services/capacitor-attachment-file-store';
|
||||||
|
import { LocalUserDataService } from '../../../../infrastructure/persistence/local-user-data.service';
|
||||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||||
|
import {
|
||||||
|
supportsDesktopDataFolderActions,
|
||||||
|
supportsLocalDataManagement,
|
||||||
|
supportsMobileLocalDataErase
|
||||||
|
} from '../../domain/logic/data-settings-capability.rules';
|
||||||
|
|
||||||
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||||
|
|
||||||
@@ -42,9 +50,25 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
|||||||
})
|
})
|
||||||
export class DataSettingsComponent {
|
export class DataSettingsComponent {
|
||||||
private readonly electron = inject(ElectronBridgeService);
|
private readonly electron = inject(ElectronBridgeService);
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly localUserData = inject(LocalUserDataService);
|
||||||
|
private readonly capacitorAttachmentStore = inject(CapacitorAttachmentFileStore);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
readonly isElectron = this.electron.isAvailable;
|
readonly isElectron = this.electron.isAvailable;
|
||||||
|
readonly isCapacitor = this.platform.isCapacitor;
|
||||||
|
readonly supportsLocalDataManagement = supportsLocalDataManagement({
|
||||||
|
isElectron: this.isElectron,
|
||||||
|
isCapacitor: this.isCapacitor
|
||||||
|
});
|
||||||
|
readonly supportsDesktopDataFolderActions = supportsDesktopDataFolderActions({
|
||||||
|
isElectron: this.isElectron,
|
||||||
|
isCapacitor: this.isCapacitor
|
||||||
|
});
|
||||||
|
readonly supportsMobileLocalDataErase = supportsMobileLocalDataErase({
|
||||||
|
isElectron: this.isElectron,
|
||||||
|
isCapacitor: this.isCapacitor
|
||||||
|
});
|
||||||
readonly dataPath = signal<string | null>(null);
|
readonly dataPath = signal<string | null>(null);
|
||||||
readonly busyAction = signal<DataAction | null>(null);
|
readonly busyAction = signal<DataAction | null>(null);
|
||||||
readonly statusMessage = signal<string | null>(null);
|
readonly statusMessage = signal<string | null>(null);
|
||||||
@@ -106,6 +130,14 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.runAction('erase', async () => {
|
await this.runAction('erase', async () => {
|
||||||
|
if (this.supportsMobileLocalDataErase) {
|
||||||
|
const result = await this.localUserData.eraseLocalUserData();
|
||||||
|
|
||||||
|
this.restartRequired.set(result.restartRequired);
|
||||||
|
this.statusMessage.set(this.appI18n.instant('settings.data.messages.erasedMobile'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.electron.requireApi().eraseUserData();
|
const result = await this.electron.requireApi().eraseUserData();
|
||||||
|
|
||||||
this.restartRequired.set(result.restartRequired);
|
this.restartRequired.set(result.restartRequired);
|
||||||
@@ -121,14 +153,18 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadDataPath(): Promise<void> {
|
private async loadDataPath(): Promise<void> {
|
||||||
if (!this.isElectron) {
|
if (this.supportsDesktopDataFolderActions) {
|
||||||
|
try {
|
||||||
|
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
||||||
|
} catch {
|
||||||
|
this.dataPath.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.supportsMobileLocalDataErase) {
|
||||||
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath());
|
||||||
} catch {
|
|
||||||
this.dataPath.set(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
class="fixed metoyou-fixed-safe-viewport z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
|
||||||
[class.opacity-100]="animating()"
|
[class.opacity-100]="animating()"
|
||||||
[class.opacity-0]="!animating()"
|
[class.opacity-0]="!animating()"
|
||||||
(click)="onBackdropClick()"
|
(click)="onBackdropClick()"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
||||||
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
<div class="fixed metoyou-fixed-safe-viewport z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||||
<div
|
<div
|
||||||
appThemeNode="settingsModalSurface"
|
appThemeNode="settingsModalSurface"
|
||||||
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
||||||
@@ -129,7 +129,21 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto border-t border-border px-3 py-3">
|
<div class="mt-auto border-t border-border px-3 py-3 space-y-2">
|
||||||
|
@if (currentUser()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="settings-logout-button"
|
||||||
|
(click)="logout()"
|
||||||
|
class="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm text-destructive transition-colors hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLogOut"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{{ 'common.logout' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openThirdPartyLicenses()"
|
(click)="openThirdPartyLicenses()"
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ import {
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { ViewportService } from '../../../core/platform';
|
import { ViewportService } from '../../../core/platform';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room, UserRole } from '../../../shared-kernel';
|
import { Room, UserRole } from '../../../shared-kernel';
|
||||||
@@ -97,7 +99,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './settings-modal.component.html'
|
templateUrl: './settings-modal.component.html'
|
||||||
@@ -106,6 +109,7 @@ export class SettingsModalComponent {
|
|||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
private theme = inject(ThemeService);
|
private theme = inject(ThemeService);
|
||||||
private themeLibrary = inject(ThemeLibraryService);
|
private themeLibrary = inject(ThemeLibraryService);
|
||||||
private viewport = inject(ViewportService);
|
private viewport = inject(ViewportService);
|
||||||
@@ -413,6 +417,11 @@ export class SettingsModalComponent {
|
|||||||
this.showThirdPartyLicenses.set(false);
|
this.showThirdPartyLicenses.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.closeForExternalNavigation();
|
||||||
|
this.userLogout.logout();
|
||||||
|
}
|
||||||
|
|
||||||
navigate(page: SettingsPage): void {
|
navigate(page: SettingsPage): void {
|
||||||
this.modal.navigate(page);
|
this.modal.navigate(page);
|
||||||
|
|
||||||
|
|||||||
@@ -31,15 +31,13 @@ import {
|
|||||||
selectIsSignalServerReconnecting,
|
selectIsSignalServerReconnecting,
|
||||||
selectSignalServerCompatibilityError
|
selectSignalServerCompatibilityError
|
||||||
} from '../../../store/rooms/rooms.selectors';
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { PlatformService } from '../../../core/platform';
|
import { PlatformService } from '../../../core/platform';
|
||||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
||||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
||||||
@@ -94,6 +92,7 @@ export class TitleBarComponent {
|
|||||||
private pluginRegistry = inject(PluginRegistryService);
|
private pluginRegistry = inject(PluginRegistryService);
|
||||||
private pluginRequirements = inject(PluginRequirementStateService);
|
private pluginRequirements = inject(PluginRequirementStateService);
|
||||||
private pluginStore = inject(PluginStoreService);
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
|
|
||||||
private getWindowControlsApi() {
|
private getWindowControlsApi() {
|
||||||
return this.electronBridge.getApi();
|
return this.electronBridge.getApi();
|
||||||
@@ -376,16 +375,7 @@ export class TitleBarComponent {
|
|||||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
this.userLogout.logout();
|
||||||
// servers the user was a member of, so other users see them go offline.
|
|
||||||
this.webrtc.disconnect();
|
|
||||||
|
|
||||||
clearStoredCurrentUserId();
|
|
||||||
this.store.dispatch(MessagesActions.clearMessages());
|
|
||||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
|
||||||
this.store.dispatch(UsersActions.resetUsersState());
|
|
||||||
|
|
||||||
this.router.navigate(['/login']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||||
|
|||||||
@@ -78,10 +78,12 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
if (PushNotifications) {
|
if (PushNotifications) {
|
||||||
const permission = await PushNotifications.checkPermissions();
|
const permission = await PushNotifications.checkPermissions();
|
||||||
|
|
||||||
if (permission.receive === 'prompt') {
|
if (permission.receive !== 'granted') {
|
||||||
await PushNotifications.requestPermissions();
|
await PushNotifications.requestPermissions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission(): Promise<boolean> {
|
async requestPermission(): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
|
||||||
|
|
||||||
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
||||||
|
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||||
|
|
||||||
export interface MetoyouMobilePlugin {
|
export interface MetoyouMobilePlugin {
|
||||||
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
||||||
@@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | n
|
|||||||
|
|
||||||
if (!metoyouMobilePluginPromise) {
|
if (!metoyouMobilePluginPromise) {
|
||||||
metoyouMobilePluginPromise = import('@capacitor/core')
|
metoyouMobilePluginPromise = import('@capacitor/core')
|
||||||
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
|
.then(async ({ Capacitor, registerPlugin }) => {
|
||||||
|
if (!Capacitor.isPluginAvailable('MetoyouMobile')) {
|
||||||
|
console.warn('[mobile] MetoyouMobile plugin is not implemented on this shell.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
|
||||||
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './logic/platform-detection.rules';
|
export * from './logic/platform-detection.rules';
|
||||||
export * from './logic/call-notification.rules';
|
export * from './logic/call-notification.rules';
|
||||||
|
export * from './services/mobile-runtime-permissions.service';
|
||||||
export * from './services/mobile-platform.service';
|
export * from './services/mobile-platform.service';
|
||||||
export * from './services/mobile-notifications.service';
|
export * from './services/mobile-notifications.service';
|
||||||
export * from './services/mobile-media.service';
|
export * from './services/mobile-media.service';
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
const pluginState = vi.hoisted(() => ({
|
||||||
|
plugin: null as null | {
|
||||||
|
requestVoiceCapturePermissions: () => Promise<{ microphone?: string }>;
|
||||||
|
requestCameraCapturePermissions: () => Promise<{ camera?: string }>;
|
||||||
|
},
|
||||||
|
isNative: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
||||||
|
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platform-detection.rules', () => ({
|
||||||
|
detectRuntimePlatform: vi.fn(({ capacitorIsNative }: { capacitorIsNative: boolean }) =>
|
||||||
|
capacitorIsNative ? 'capacitor' : 'browser'),
|
||||||
|
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||||
|
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from './ensure-mobile-capture-permissions';
|
||||||
|
|
||||||
|
describe('ensure-mobile-capture-permissions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pluginState.isNative = true;
|
||||||
|
pluginState.plugin = {
|
||||||
|
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'granted' })),
|
||||||
|
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' }))
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(loadMetoyouMobilePlugin).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips native preflight on browser shells', async () => {
|
||||||
|
pluginState.isNative = false;
|
||||||
|
|
||||||
|
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
|
||||||
|
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers to WebView capture when the native plugin is unavailable', async () => {
|
||||||
|
pluginState.plugin = null;
|
||||||
|
|
||||||
|
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
|
||||||
|
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers to WebView capture when the native plugin call fails', async () => {
|
||||||
|
pluginState.plugin = {
|
||||||
|
requestVoiceCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
|
||||||
|
requestCameraCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED')))
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
|
||||||
|
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks capture when the native shell explicitly denies microphone access', async () => {
|
||||||
|
pluginState.plugin = {
|
||||||
|
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
|
||||||
|
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' }))
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isVoiceCaptureAllowed(result);
|
return isVoiceCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isCameraCaptureAllowed(result);
|
return isCameraCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
@@ -9,13 +10,16 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
REQUIRED_SPLASH_FILES
|
REQUIRED_SPLASH_FILES,
|
||||||
|
resolveIconPixelSize,
|
||||||
|
SPLASH_ICON_RATIO
|
||||||
} from './mobile-android-launcher-icon.rules';
|
} from './mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
|
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
|
||||||
@@ -31,6 +35,12 @@ describe('mobile-android-launcher-icon.rules', () => {
|
|||||||
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
||||||
|
|
||||||
|
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
|
||||||
|
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
|
||||||
|
expect(resolveIconPixelSize(432)).toBe(264);
|
||||||
|
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
|
||||||
|
});
|
||||||
|
|
||||||
it('ships a launcher icon and splash for every required density', () => {
|
it('ships a launcher icon and splash for every required density', () => {
|
||||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -49,4 +59,15 @@ describe('mobile-android-launcher-icon.rules', () => {
|
|||||||
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||||
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
|
||||||
|
const foregroundPath = resolve(RES_DIR, 'mipmap-xxxhdpi/ic_launcher_foreground.png');
|
||||||
|
const { data, info } = await sharp(foregroundPath).ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
|
||||||
|
const topCenterAlpha = data[topCenterOffset + 3];
|
||||||
|
|
||||||
|
expect(topCenterAlpha).toBeLessThan(32);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,26 @@
|
|||||||
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
||||||
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
||||||
|
|
||||||
|
/** Adaptive-icon foreground canvas size in dp (Android spec). */
|
||||||
|
export const ADAPTIVE_ICON_CANVAS_DP = 108;
|
||||||
|
|
||||||
|
/** Visible safe-zone diameter inside the adaptive-icon foreground layer (Android spec). */
|
||||||
|
export const ADAPTIVE_ICON_SAFE_ZONE_DP = 66;
|
||||||
|
|
||||||
|
/** Scale the brand mark to fit inside the adaptive-icon safe zone instead of full-bleed. */
|
||||||
|
export const ADAPTIVE_FOREGROUND_ICON_RATIO = ADAPTIVE_ICON_SAFE_ZONE_DP / ADAPTIVE_ICON_CANVAS_DP;
|
||||||
|
|
||||||
|
/** Legacy square/round launcher bitmaps use the same inset so circular masks do not clip the cat. */
|
||||||
|
export const LEGACY_LAUNCHER_ICON_RATIO = ADAPTIVE_FOREGROUND_ICON_RATIO;
|
||||||
|
|
||||||
|
/** Splash art keeps the brand mark smaller than the screen so ears and cheeks stay visible. */
|
||||||
|
export const SPLASH_ICON_RATIO = 0.32;
|
||||||
|
|
||||||
|
/** Return the brand icon pixel size for a square canvas at the given scale ratio. */
|
||||||
|
export function resolveIconPixelSize(canvasPx: number, ratio: number = ADAPTIVE_FOREGROUND_ICON_RATIO): number {
|
||||||
|
return Math.round(canvasPx * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
/** Density buckets Android resolves launcher icons from. */
|
/** Density buckets Android resolves launcher icons from. */
|
||||||
export const ANDROID_ICON_DENSITIES = [
|
export const ANDROID_ICON_DENSITIES = [
|
||||||
'mdpi',
|
'mdpi',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
areMobileRuntimePermissionsGranted,
|
||||||
|
shouldPromptForNotificationPermission,
|
||||||
|
shouldRequestMobileRuntimePermissions
|
||||||
|
} from './mobile-runtime-permissions.rules';
|
||||||
|
|
||||||
|
describe('mobile-runtime-permissions.rules', () => {
|
||||||
|
it('only requests runtime permissions on Capacitor shells', () => {
|
||||||
|
expect(shouldRequestMobileRuntimePermissions('capacitor')).toBe(true);
|
||||||
|
expect(shouldRequestMobileRuntimePermissions('browser')).toBe(false);
|
||||||
|
expect(shouldRequestMobileRuntimePermissions('electron')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts for notification permissions until granted', () => {
|
||||||
|
expect(shouldPromptForNotificationPermission('granted')).toBe(false);
|
||||||
|
expect(shouldPromptForNotificationPermission('prompt')).toBe(true);
|
||||||
|
expect(shouldPromptForNotificationPermission('denied')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every runtime permission alias to be granted', () => {
|
||||||
|
expect(areMobileRuntimePermissionsGranted({
|
||||||
|
voiceGranted: true,
|
||||||
|
cameraGranted: true,
|
||||||
|
localNotificationsGranted: true,
|
||||||
|
pushNotificationsGranted: true
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(areMobileRuntimePermissionsGranted({
|
||||||
|
voiceGranted: false,
|
||||||
|
cameraGranted: true,
|
||||||
|
localNotificationsGranted: true,
|
||||||
|
pushNotificationsGranted: true
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RuntimePlatform } from './platform-detection.rules';
|
||||||
|
|
||||||
|
export type MobileNotificationPermissionState = 'granted' | 'denied' | 'prompt' | string;
|
||||||
|
|
||||||
|
/** Capacitor shells should preflight Android/iOS runtime permissions during bootstrap. */
|
||||||
|
export function shouldRequestMobileRuntimePermissions(runtime: RuntimePlatform): boolean {
|
||||||
|
return runtime === 'capacitor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a notification permission alias still needs a runtime prompt. */
|
||||||
|
export function shouldPromptForNotificationPermission(state: MobileNotificationPermissionState | undefined): boolean {
|
||||||
|
return state !== 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether every requested runtime permission alias was granted. */
|
||||||
|
export function areMobileRuntimePermissionsGranted(snapshot: {
|
||||||
|
voiceGranted: boolean;
|
||||||
|
cameraGranted: boolean;
|
||||||
|
localNotificationsGranted: boolean;
|
||||||
|
pushNotificationsGranted: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return snapshot.voiceGranted
|
||||||
|
&& snapshot.cameraGranted
|
||||||
|
&& snapshot.localNotificationsGranted
|
||||||
|
&& snapshot.pushNotificationsGranted;
|
||||||
|
}
|
||||||
@@ -1,17 +1,53 @@
|
|||||||
import {
|
import {
|
||||||
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it
|
it,
|
||||||
|
vi
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import { applyMobileSafeAreaDefaults, getSafeAreaInsetCSSValue } from './mobile-safe-area.rules';
|
import {
|
||||||
|
applyMobileSafeAreaDefaults,
|
||||||
|
getSafeAreaInsetCSSValue,
|
||||||
|
MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS,
|
||||||
|
MOBILE_FIXED_SAFE_VIEWPORT_CLASS,
|
||||||
|
MOBILE_SAFE_AREA_SHELL_CLASS,
|
||||||
|
syncMobileSafeAreaInsets
|
||||||
|
} from './mobile-safe-area.rules';
|
||||||
|
|
||||||
|
const systemBars = vi.hoisted(() => ({
|
||||||
|
setStyle: vi.fn(() => Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@capacitor/core', () => ({
|
||||||
|
SystemBars: systemBars,
|
||||||
|
SystemBarsStyle: { Dark: 'DARK' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platform-detection.rules', () => ({
|
||||||
|
isCapacitorNativeRuntime: vi.fn(() => false)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||||
|
|
||||||
describe('mobile-safe-area.rules', () => {
|
describe('mobile-safe-area.rules', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(false);
|
||||||
|
systemBars.setStyle.mockClear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it('builds CSS values with Capacitor and env fallbacks', () => {
|
it('builds CSS values with Capacitor and env fallbacks', () => {
|
||||||
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
|
expect(getSafeAreaInsetCSSValue('top')).toBe('var(--safe-area-inset-top, env(safe-area-inset-top, 0px))');
|
||||||
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
|
expect(getSafeAreaInsetCSSValue('bottom')).toBe('var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exports the global safe-area layout classes used by the mobile shell and overlays', () => {
|
||||||
|
expect(MOBILE_FIXED_SAFE_VIEWPORT_CLASS).toBe('metoyou-fixed-safe-viewport');
|
||||||
|
expect(MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS).toBe('metoyou-fixed-safe-bottom-sheet');
|
||||||
|
expect(MOBILE_SAFE_AREA_SHELL_CLASS).toBe('metoyou-safe-area-shell');
|
||||||
|
});
|
||||||
|
|
||||||
it('sets default safe-area variables on the document root', () => {
|
it('sets default safe-area variables on the document root', () => {
|
||||||
const properties = new Map<string, string>();
|
const properties = new Map<string, string>();
|
||||||
const root = {
|
const root = {
|
||||||
@@ -34,4 +70,18 @@ describe('mobile-safe-area.rules', () => {
|
|||||||
it('ignores null roots instead of throwing', () => {
|
it('ignores null roots instead of throwing', () => {
|
||||||
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
|
expect(() => applyMobileSafeAreaDefaults(null)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asks Capacitor SystemBars to refresh CSS insets on native shells', async () => {
|
||||||
|
const onDOMReady = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(isCapacitorNativeRuntime).mockReturnValue(true);
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
CapacitorSystemBarsAndroidInterface: { onDOMReady }
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncMobileSafeAreaInsets();
|
||||||
|
|
||||||
|
expect(onDOMReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(systemBars.setStyle).toHaveBeenCalledWith({ style: 'DARK' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||||
|
|
||||||
const SAFE_AREA_SIDES = [
|
const SAFE_AREA_SIDES = [
|
||||||
'top',
|
'top',
|
||||||
'right',
|
'right',
|
||||||
@@ -7,6 +9,15 @@ const SAFE_AREA_SIDES = [
|
|||||||
|
|
||||||
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
|
export type SafeAreaSide = (typeof SAFE_AREA_SIDES)[number];
|
||||||
|
|
||||||
|
/** Global class that insets fixed full-viewport overlays below the status bar and above the nav bar. */
|
||||||
|
export const MOBILE_FIXED_SAFE_VIEWPORT_CLASS = 'metoyou-fixed-safe-viewport';
|
||||||
|
|
||||||
|
/** Global class for bottom sheets that anchor above the Android navigation bar. */
|
||||||
|
export const MOBILE_FIXED_SAFE_BOTTOM_SHEET_CLASS = 'metoyou-fixed-safe-bottom-sheet';
|
||||||
|
|
||||||
|
/** Global class applied to the mobile app shell so routed content respects system insets. */
|
||||||
|
export const MOBILE_SAFE_AREA_SHELL_CLASS = 'metoyou-safe-area-shell';
|
||||||
|
|
||||||
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
|
/** CSS value chain for one safe-area inset (Capacitor vars with env() fallback). */
|
||||||
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
|
export function getSafeAreaInsetCSSValue(side: SafeAreaSide): string {
|
||||||
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
|
return `var(--safe-area-inset-${side}, env(safe-area-inset-${side}, 0px))`;
|
||||||
@@ -28,3 +39,20 @@ export function applyMobileSafeAreaDefaults(root: HTMLElement | null = typeof do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request Capacitor Android SystemBars to recompute CSS inset variables after the SPA boots. */
|
||||||
|
export async function syncMobileSafeAreaInsets(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || !isCapacitorNativeRuntime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidInterface = (window as Window & {
|
||||||
|
CapacitorSystemBarsAndroidInterface?: { onDOMReady?: () => void };
|
||||||
|
}).CapacitorSystemBarsAndroidInterface;
|
||||||
|
|
||||||
|
androidInterface?.onDOMReady?.();
|
||||||
|
|
||||||
|
const { SystemBars, SystemBarsStyle } = await import('@capacitor/core');
|
||||||
|
|
||||||
|
await SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { requestMobileRuntimePermissions } from './request-mobile-runtime-permissions';
|
||||||
|
|
||||||
|
describe('requestMobileRuntimePermissions', () => {
|
||||||
|
const loadMetoyouMobilePlugin = vi.fn();
|
||||||
|
const loadLocalNotifications = vi.fn();
|
||||||
|
const loadPushNotifications = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
loadMetoyouMobilePlugin.mockReset();
|
||||||
|
loadLocalNotifications.mockReset();
|
||||||
|
loadPushNotifications.mockReset();
|
||||||
|
|
||||||
|
loadMetoyouMobilePlugin.mockResolvedValue({
|
||||||
|
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'granted' })),
|
||||||
|
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' }))
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLocalNotifications.mockResolvedValue({
|
||||||
|
checkPermissions: vi.fn(() => Promise.resolve({ display: 'prompt' })),
|
||||||
|
requestPermissions: vi.fn(() => Promise.resolve({ display: 'granted' }))
|
||||||
|
});
|
||||||
|
|
||||||
|
loadPushNotifications.mockResolvedValue({
|
||||||
|
checkPermissions: vi.fn(() => Promise.resolve({ receive: 'prompt' })),
|
||||||
|
requestPermissions: vi.fn(() => Promise.resolve({ receive: 'granted' }))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on non-Capacitor shells', async () => {
|
||||||
|
await expect(requestMobileRuntimePermissions({
|
||||||
|
runtime: 'browser',
|
||||||
|
loadMetoyouMobilePlugin,
|
||||||
|
loadLocalNotifications,
|
||||||
|
loadPushNotifications
|
||||||
|
})).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests voice, camera, and notification permissions on Capacitor shells', async () => {
|
||||||
|
const snapshot = await requestMobileRuntimePermissions({
|
||||||
|
runtime: 'capacitor',
|
||||||
|
loadMetoyouMobilePlugin,
|
||||||
|
loadLocalNotifications,
|
||||||
|
loadPushNotifications
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toEqual({
|
||||||
|
voiceGranted: true,
|
||||||
|
cameraGranted: true,
|
||||||
|
localNotificationsGranted: true,
|
||||||
|
pushNotificationsGranted: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadMetoyouMobilePlugin).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const localNotifications = await loadLocalNotifications.mock.results[0]?.value;
|
||||||
|
const pushNotifications = await loadPushNotifications.mock.results[0]?.value;
|
||||||
|
|
||||||
|
expect(localNotifications.requestPermissions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushNotifications.requestPermissions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still requests notification permissions when the native media plugin is unavailable', async () => {
|
||||||
|
loadMetoyouMobilePlugin.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const snapshot = await requestMobileRuntimePermissions({
|
||||||
|
runtime: 'capacitor',
|
||||||
|
loadMetoyouMobilePlugin,
|
||||||
|
loadLocalNotifications,
|
||||||
|
loadPushNotifications
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot).toEqual({
|
||||||
|
voiceGranted: false,
|
||||||
|
cameraGranted: false,
|
||||||
|
localNotificationsGranted: true,
|
||||||
|
pushNotificationsGranted: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { RuntimePlatform } from './platform-detection.rules';
|
||||||
|
import {
|
||||||
|
isCameraCaptureAllowed,
|
||||||
|
isVoiceCaptureAllowed,
|
||||||
|
type MobileCapturePermissionResult
|
||||||
|
} from './mobile-media-permission.rules';
|
||||||
|
import { shouldPromptForNotificationPermission, shouldRequestMobileRuntimePermissions } from './mobile-runtime-permissions.rules';
|
||||||
|
|
||||||
|
export interface MobileRuntimePermissionSnapshot {
|
||||||
|
voiceGranted: boolean;
|
||||||
|
cameraGranted: boolean;
|
||||||
|
localNotificationsGranted: boolean;
|
||||||
|
pushNotificationsGranted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileRuntimePermissionRequestDeps {
|
||||||
|
runtime: RuntimePlatform;
|
||||||
|
loadMetoyouMobilePlugin: () => Promise<{
|
||||||
|
requestVoiceCapturePermissions: () => Promise<MobileCapturePermissionResult>;
|
||||||
|
requestCameraCapturePermissions: () => Promise<MobileCapturePermissionResult>;
|
||||||
|
} | null>;
|
||||||
|
loadLocalNotifications: () => Promise<{
|
||||||
|
checkPermissions: () => Promise<{ display?: string }>;
|
||||||
|
requestPermissions: () => Promise<{ display?: string }>;
|
||||||
|
} | null>;
|
||||||
|
loadPushNotifications: () => Promise<{
|
||||||
|
checkPermissions: () => Promise<{ receive?: string }>;
|
||||||
|
requestPermissions: () => Promise<{ receive?: string }>;
|
||||||
|
} | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prompt for the Android/iOS runtime permissions the mobile shell needs at startup. */
|
||||||
|
export async function requestMobileRuntimePermissions(
|
||||||
|
deps: MobileRuntimePermissionRequestDeps
|
||||||
|
): Promise<MobileRuntimePermissionSnapshot | null> {
|
||||||
|
if (!shouldRequestMobileRuntimePermissions(deps.runtime)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await deps.loadMetoyouMobilePlugin();
|
||||||
|
const localNotifications = await deps.loadLocalNotifications();
|
||||||
|
const pushNotifications = await deps.loadPushNotifications();
|
||||||
|
const voiceGranted = await requestVoiceCapturePermission(plugin);
|
||||||
|
const cameraGranted = await requestCameraCapturePermission(plugin);
|
||||||
|
const localNotificationsGranted = await requestLocalNotificationPermission(localNotifications);
|
||||||
|
const pushNotificationsGranted = await requestPushNotificationPermission(pushNotifications);
|
||||||
|
|
||||||
|
return {
|
||||||
|
voiceGranted,
|
||||||
|
cameraGranted,
|
||||||
|
localNotificationsGranted,
|
||||||
|
pushNotificationsGranted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestVoiceCapturePermission(
|
||||||
|
plugin: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadMetoyouMobilePlugin']>>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!plugin?.requestVoiceCapturePermissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await plugin.requestVoiceCapturePermissions();
|
||||||
|
|
||||||
|
return isVoiceCaptureAllowed(result);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCameraCapturePermission(
|
||||||
|
plugin: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadMetoyouMobilePlugin']>>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!plugin?.requestCameraCapturePermissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await plugin.requestCameraCapturePermissions();
|
||||||
|
|
||||||
|
return isCameraCaptureAllowed(result);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLocalNotificationPermission(
|
||||||
|
localNotifications: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadLocalNotifications']>>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!localNotifications) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await localNotifications.checkPermissions();
|
||||||
|
|
||||||
|
if (!shouldPromptForNotificationPermission(permission.display)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = await localNotifications.requestPermissions();
|
||||||
|
|
||||||
|
return requested.display === 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPushNotificationPermission(
|
||||||
|
pushNotifications: Awaited<ReturnType<MobileRuntimePermissionRequestDeps['loadPushNotifications']>>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!pushNotifications) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await pushNotifications.checkPermissions();
|
||||||
|
|
||||||
|
if (!shouldPromptForNotificationPermission(permission.receive)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = await pushNotifications.requestPermissions();
|
||||||
|
|
||||||
|
return requested.receive === 'granted';
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
|
import { syncMobileSafeAreaInsets } from '../logic/mobile-safe-area.rules';
|
||||||
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
import type { MobileAppLifecycleAdapter } from '../contracts/mobile.contracts';
|
||||||
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
import { WebMobileAppLifecycleAdapter } from '../adapters/web/web-mobile-app-lifecycle.adapter';
|
||||||
import { MobilePlatformService } from './mobile-platform.service';
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
|
||||||
|
|
||||||
/** Facade for foreground/background lifecycle events. */
|
/** Facade for foreground/background lifecycle events. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MobileAppLifecycleService {
|
export class MobileAppLifecycleService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
|
||||||
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
||||||
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
@@ -22,6 +25,8 @@ export class MobileAppLifecycleService {
|
|||||||
|
|
||||||
await adapter.initialize();
|
await adapter.initialize();
|
||||||
this.mobilePlatform.refreshRuntimeDetection();
|
this.mobilePlatform.refreshRuntimeDetection();
|
||||||
|
await syncMobileSafeAreaInsets();
|
||||||
|
await this.runtimePermissions.initialize();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||||
|
import { requestMobileRuntimePermissions } from '../logic/request-mobile-runtime-permissions';
|
||||||
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
|
||||||
|
|
||||||
|
vi.mock('../logic/request-mobile-runtime-permissions', () => ({
|
||||||
|
requestMobileRuntimePermissions: vi.fn(() => Promise.resolve({
|
||||||
|
voiceGranted: true,
|
||||||
|
cameraGranted: true,
|
||||||
|
localNotificationsGranted: true,
|
||||||
|
pushNotificationsGranted: true
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createService(runtime: 'browser' | 'capacitor'): MobileRuntimePermissionsService {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
MobileRuntimePermissionsService,
|
||||||
|
MobilePlatformService,
|
||||||
|
{
|
||||||
|
provide: ElectronBridgeService,
|
||||||
|
useValue: { isAvailable: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ViewportService,
|
||||||
|
useValue: {
|
||||||
|
isMobile: () => runtime === 'capacitor'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runtime === 'capacitor') {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Capacitor: {
|
||||||
|
isNativePlatform: () => true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => injector.get(MobileRuntimePermissionsService));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MobileRuntimePermissionsService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(requestMobileRuntimePermissions).mockClear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests runtime permissions once on Capacitor shells', async () => {
|
||||||
|
const service = createService('capacitor');
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
expect(requestMobileRuntimePermissions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not request runtime permissions on browser shells', async () => {
|
||||||
|
const service = createService('browser');
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
expect(requestMobileRuntimePermissions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(requestMobileRuntimePermissions).mock.calls[0]?.[0].runtime).toBe('browser');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from '../adapters/capacitor/capacitor-plugin-loader';
|
||||||
|
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||||
|
import { requestMobileRuntimePermissions } from '../logic/request-mobile-runtime-permissions';
|
||||||
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
|
||||||
|
/** Prompts for Android/iOS runtime permissions once per Capacitor shell startup. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MobileRuntimePermissionsService {
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
private initialized = false;
|
||||||
|
private initializePromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
initialize(): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.initializePromise) {
|
||||||
|
this.initializePromise = this.requestPermissions().finally(() => {
|
||||||
|
this.initialized = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.initializePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestPermissions(): Promise<void> {
|
||||||
|
await requestMobileRuntimePermissions({
|
||||||
|
runtime: this.mobilePlatform.runtime(),
|
||||||
|
loadMetoyouMobilePlugin,
|
||||||
|
loadLocalNotifications: loadCapacitorLocalNotificationsPlugin,
|
||||||
|
loadPushNotifications: loadCapacitorPushNotificationsPlugin
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../core/platform';
|
||||||
|
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||||
|
import { UserLogoutService } from '../../domains/authentication/application/services/user-logout.service';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
import { LocalUserDataService } from './local-user-data.service';
|
||||||
|
|
||||||
|
const filesystemMocks = vi.hoisted(() => ({
|
||||||
|
rmdir: vi.fn(() => Promise.resolve())
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../domains/attachment/infrastructure/services/capacitor-attachment-filesystem.adapter', () => ({
|
||||||
|
loadCapacitorAttachmentFilesystem: vi.fn(async () => ({
|
||||||
|
filesystem: {
|
||||||
|
rmdir: filesystemMocks.rmdir
|
||||||
|
},
|
||||||
|
directory: 'DATA',
|
||||||
|
convertFileSrc: (url: string) => url
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LocalUserDataService', () => {
|
||||||
|
let database: { clearAllData: ReturnType<typeof vi.fn> };
|
||||||
|
let authTokenStore: { clearAllTokens: ReturnType<typeof vi.fn> };
|
||||||
|
let userLogout: { logout: ReturnType<typeof vi.fn> };
|
||||||
|
let service: LocalUserDataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||||
|
removeItem: (key: string) => { storage.delete(key); },
|
||||||
|
clear: () => { storage.clear(); },
|
||||||
|
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
||||||
|
get length() {
|
||||||
|
return storage.size;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('metoyou_currentUserId', 'user-1');
|
||||||
|
localStorage.setItem('metoyou_voice_settings', '{}');
|
||||||
|
localStorage.setItem('metoyou.authTokens', '{"http://localhost:3001":{"token":"abc","expiresAt":9999999999999}}');
|
||||||
|
|
||||||
|
database = { clearAllData: vi.fn(() => Promise.resolve()) };
|
||||||
|
authTokenStore = { clearAllTokens: vi.fn() };
|
||||||
|
userLogout = { logout: vi.fn() };
|
||||||
|
filesystemMocks.rmdir.mockClear();
|
||||||
|
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
LocalUserDataService,
|
||||||
|
{ provide: PlatformService, useValue: { isCapacitor: true, isElectron: false, isBrowser: false } },
|
||||||
|
{ provide: DatabaseService, useValue: database },
|
||||||
|
{ provide: AuthTokenStoreService, useValue: authTokenStore },
|
||||||
|
{ provide: UserLogoutService, useValue: userLogout }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = runInInjectionContext(injector, () => injector.get(LocalUserDataService));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears native storage, auth tokens, and logs the user out', async () => {
|
||||||
|
const result = await service.eraseLocalUserData();
|
||||||
|
|
||||||
|
expect(database.clearAllData).toHaveBeenCalledOnce();
|
||||||
|
expect(filesystemMocks.rmdir).toHaveBeenCalledWith({
|
||||||
|
path: 'metoyou',
|
||||||
|
directory: 'DATA',
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authTokenStore.clearAllTokens).toHaveBeenCalledOnce();
|
||||||
|
expect(localStorage.getItem('metoyou_currentUserId')).toBeNull();
|
||||||
|
expect(localStorage.getItem('metoyou_voice_settings')).toBeNull();
|
||||||
|
expect(userLogout.logout).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({ restartRequired: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects erase on non-native shells', async () => {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
LocalUserDataService,
|
||||||
|
{ provide: PlatformService, useValue: { isCapacitor: false, isElectron: false, isBrowser: true } },
|
||||||
|
{ provide: DatabaseService, useValue: database },
|
||||||
|
{ provide: AuthTokenStoreService, useValue: authTokenStore },
|
||||||
|
{ provide: UserLogoutService, useValue: userLogout }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const browserService = runInInjectionContext(injector, () => injector.get(LocalUserDataService));
|
||||||
|
|
||||||
|
await expect(browserService.eraseLocalUserData()).rejects.toThrow(/native mobile/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../core/platform';
|
||||||
|
import { clearStoredLocalAppData } from '../../core/storage/current-user-storage';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
import { UserLogoutService } from '../../domains/authentication/application/services/user-logout.service';
|
||||||
|
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||||
|
import { loadCapacitorAttachmentFilesystem } from '../../domains/attachment/infrastructure/services/capacitor-attachment-filesystem.adapter';
|
||||||
|
|
||||||
|
const CAPACITOR_APP_DATA_ROOT = 'metoyou';
|
||||||
|
|
||||||
|
export interface EraseLocalUserDataResult {
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LocalUserDataService {
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly database = inject(DatabaseService);
|
||||||
|
private readonly userLogout = inject(UserLogoutService);
|
||||||
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
|
||||||
|
async eraseLocalUserData(): Promise<EraseLocalUserDataResult> {
|
||||||
|
if (!this.platform.isCapacitor) {
|
||||||
|
throw new Error('Local user data erase is only supported on native mobile shells.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.database.clearAllData();
|
||||||
|
await this.deleteCapacitorAttachmentTree();
|
||||||
|
this.authTokenStore.clearAllTokens();
|
||||||
|
clearStoredLocalAppData();
|
||||||
|
this.userLogout.logout();
|
||||||
|
|
||||||
|
return { restartRequired: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteCapacitorAttachmentTree(): Promise<void> {
|
||||||
|
const filesystem = await loadCapacitorAttachmentFilesystem();
|
||||||
|
|
||||||
|
if (!filesystem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await filesystem.filesystem.rmdir({
|
||||||
|
path: CAPACITOR_APP_DATA_ROOT,
|
||||||
|
directory: filesystem.directory,
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Missing directory is fine during erase.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
appThemeNode="bottomSheetSurface"
|
appThemeNode="bottomSheetSurface"
|
||||||
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
class="bottom-sheet-panel fixed metoyou-fixed-safe-bottom-sheet z-[141] flex flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
|
||||||
[style.transform]="'translateY(' + translateY() + 'px)'"
|
[style.transform]="'translateY(' + translateY() + 'px)'"
|
||||||
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
|
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
|
||||||
[attr.aria-label]="title() || ariaLabel()"
|
[attr.aria-label]="title() || ariaLabel()"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@if (dismissable()) {
|
@if (dismissable()) {
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60"
|
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||||
[class.backdrop-blur-sm]="blur()"
|
[class.backdrop-blur-sm]="blur()"
|
||||||
[style.z-index]="zIndex()"
|
[style.z-index]="zIndex()"
|
||||||
(click)="dismiss()"
|
(click)="dismiss()"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
></div>
|
></div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/60"
|
class="fixed metoyou-fixed-safe-viewport bg-black/60"
|
||||||
[class.backdrop-blur-sm]="blur()"
|
[class.backdrop-blur-sm]="blur()"
|
||||||
[style.z-index]="zIndex()"
|
[style.z-index]="zIndex()"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|||||||
@@ -37,7 +37,10 @@
|
|||||||
.metoyou-bottom-sheet-panel {
|
.metoyou-bottom-sheet-panel {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 85vh;
|
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))) !important;
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) !important;
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) !important;
|
||||||
border-top-left-radius: 1rem;
|
border-top-left-radius: 1rem;
|
||||||
border-top-right-radius: 1rem;
|
border-top-right-radius: 1rem;
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
@@ -126,12 +129,32 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metoyou-safe-area-shell {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metoyou-fixed-safe-viewport {
|
||||||
|
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metoyou-fixed-safe-bottom-sheet {
|
||||||
|
right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||||||
|
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||||||
|
max-height: calc(85vh - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
* Keep border-box on every element (Tailwind preflight does this too). Do not use
|
||||||
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
* `box-sizing: inherit` here — it overrides preflight and lets nested hosts fall back to
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const RES_DIR = resolve(REPO_ROOT, 'toju-app/android/app/src/main/res');
|
|||||||
const BRAND_BACKGROUND = { r: 74, g: 33, b: 122 };
|
const BRAND_BACKGROUND = { r: 74, g: 33, b: 122 };
|
||||||
const BRAND_BACKGROUND_HEX = '#4A217A';
|
const BRAND_BACKGROUND_HEX = '#4A217A';
|
||||||
|
|
||||||
|
/** Adaptive-icon safe zone (66dp inside 108dp). Keep in sync with the rules file. */
|
||||||
|
const ADAPTIVE_FOREGROUND_ICON_RATIO = 66 / 108;
|
||||||
|
const LEGACY_LAUNCHER_ICON_RATIO = ADAPTIVE_FOREGROUND_ICON_RATIO;
|
||||||
|
const SPLASH_ICON_RATIO = 0.32;
|
||||||
|
|
||||||
/** Legacy square/round launcher bitmap edge length per density. */
|
/** Legacy square/round launcher bitmap edge length per density. */
|
||||||
const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
|
const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
|
||||||
|
|
||||||
@@ -65,20 +70,36 @@ async function centeredIconOnBackground(width, height, iconRatio, background) {
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compose the brand icon centred on a transparent canvas for adaptive foreground layers. */
|
||||||
|
async function centeredIconOnTransparentCanvas(canvasSize, iconRatio) {
|
||||||
|
const iconSize = Math.round(canvasSize * iconRatio);
|
||||||
|
const icon = await squareIcon(iconSize);
|
||||||
|
|
||||||
|
return sharp({
|
||||||
|
create: {
|
||||||
|
width: canvasSize,
|
||||||
|
height: canvasSize,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.composite([{ input: icon, gravity: 'center' }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
async function generateLauncherIcons() {
|
async function generateLauncherIcons() {
|
||||||
const written = [];
|
const written = [];
|
||||||
|
|
||||||
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
|
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
|
||||||
const bitmap = await squareIcon(size);
|
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
||||||
|
const bitmap = await centeredIconOnBackground(size, size, LEGACY_LAUNCHER_ICON_RATIO, background);
|
||||||
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`));
|
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`));
|
||||||
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`));
|
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive foreground: full-bleed circle on transparent canvas. The adaptive
|
|
||||||
// background layer paints the brand purple, so the masked result matches the
|
|
||||||
// source disc on every launcher mask shape.
|
|
||||||
for (const [density, size] of Object.entries(FOREGROUND_PX)) {
|
for (const [density, size] of Object.entries(FOREGROUND_PX)) {
|
||||||
const foreground = await squareIcon(size);
|
const foreground = await centeredIconOnTransparentCanvas(size, ADAPTIVE_FOREGROUND_ICON_RATIO);
|
||||||
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
|
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,14 +118,13 @@ async function generateSplashScreens() {
|
|||||||
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
||||||
|
|
||||||
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
|
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
|
||||||
const portrait = await centeredIconOnBackground(portW, portH, 0.4, background);
|
const portrait = await centeredIconOnBackground(portW, portH, SPLASH_ICON_RATIO, background);
|
||||||
const landscape = await centeredIconOnBackground(portH, portW, 0.4, background);
|
const landscape = await centeredIconOnBackground(portH, portW, SPLASH_ICON_RATIO, background);
|
||||||
written.push(await writePng(portrait, `drawable-port-${density}/splash.png`));
|
written.push(await writePng(portrait, `drawable-port-${density}/splash.png`));
|
||||||
written.push(await writePng(landscape, `drawable-land-${density}/splash.png`));
|
written.push(await writePng(landscape, `drawable-land-${density}/splash.png`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base fallback splash mirrors the landscape mdpi geometry (Capacitor default).
|
const base = await centeredIconOnBackground(480, 320, SPLASH_ICON_RATIO, background);
|
||||||
const base = await centeredIconOnBackground(480, 320, 0.4, background);
|
|
||||||
written.push(await writePng(base, 'drawable/splash.png'));
|
written.push(await writePng(base, 'drawable/splash.png'));
|
||||||
|
|
||||||
return written;
|
return written;
|
||||||
|
|||||||