Compare commits

...

5 Commits

Author SHA1 Message Date
Myx
07e91a0d09 fix: Bug - Add logout in mobile version of settings, allow clearing data on android
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 7m55s
Queue Release Build / build-windows (push) Successful in 28m37s
Queue Release Build / build-linux (push) Successful in 47m3s
Queue Release Build / build-android (push) Successful in 20m33s
Queue Release Build / finalize (push) Successful in 3m48s
Expose settings logout on mobile where the title bar is hidden, and enable
Capacitor data settings with storage visibility and local erase/sign-out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:31:40 +02:00
Myx
cb59af6b6c fix: Bug - Login screen shows up on unreachable signal servers
Skip authorize login navigation when a signal server endpoint is offline or
unreachable; gate connection and credential flows on online status only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:08:53 +02:00
Myx
6b9a39fe4a fix: Bug - Android app view is below titlebar and action bar on android
Respect Android system bar insets with safe-area shell padding, inset-aware modal and bottom-sheet layouts, transparent edge-to-edge themes, and a Capacitor SystemBars refresh on mobile startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:24:48 +02:00
Myx
a01abbb1bf fix: Bug - Android app is zoomed in
Scale launcher and splash brand assets to the adaptive-icon safe zone and a smaller splash ratio so circular launcher masks and the launch splash no longer crop the cat face.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:20:01 +02:00
Myx
bdea95511d fix: Bug - Android app doesn't ask for permissions
Prompt for microphone, camera, and notification runtime permissions during Capacitor startup, and fall back to WebView getUserMedia when the native preflight bridge fails so voice joins still surface Android permission dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:06:51 +02:00
76 changed files with 1464 additions and 82 deletions

View File

@@ -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.

View File

@@ -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

View 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 };

View 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();
}
});
});

View File

@@ -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);
});
}); });

View 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 });
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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>

View File

@@ -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."
} }
}, },

View File

@@ -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."
} }
}, },

View File

@@ -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"

View File

@@ -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);
});
}); });

View File

@@ -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();

View File

@@ -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),

View File

@@ -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();
});
});

View File

@@ -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']);
}
}
}

View File

@@ -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);
});
});

View File

@@ -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';
}

View File

@@ -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';

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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';
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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">
@if (supportsMobileLocalDataErase) {
{{ 'settings.data.localData.descriptionMobile' | translate }}
} @else {
{{ 'settings.data.localData.description' | translate }} {{ '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,6 +124,38 @@
{{ 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>
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.descriptionMobile' | translate }}</p>
</div>
<button
type="button"
data-testid="data-settings-erase-button"
(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()) { @if (statusMessage()) {
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4"> <section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
@@ -131,5 +168,4 @@
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p> <p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
</section> </section>
} }
}
</div> </div>

View File

@@ -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,15 +153,19 @@ export class DataSettingsComponent {
} }
private async loadDataPath(): Promise<void> { private async loadDataPath(): Promise<void> {
if (!this.isElectron) { if (this.supportsDesktopDataFolderActions) {
return;
}
try { try {
this.dataPath.set(await this.electron.requireApi().getAppDataPath()); this.dataPath.set(await this.electron.requireApi().getAppDataPath());
} catch { } catch {
this.dataPath.set(null); this.dataPath.set(null);
} }
return;
}
if (this.supportsMobileLocalDataErase) {
this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath());
}
} }
private async runAction(action: DataAction, operation: () => Promise<void>): Promise<void> { private async runAction(action: DataAction, operation: () => Promise<void>): Promise<void> {

View File

@@ -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()"

View File

@@ -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);

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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);
} }

View File

@@ -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';

View File

@@ -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);
});
});

View File

@@ -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;
} }
} }

View File

@@ -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);
});
}); });

View File

@@ -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',

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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' });
});
}); });

View File

@@ -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(() => {});
}

View File

@@ -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
});
});
});

View File

@@ -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';
}

View File

@@ -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;
} }

View File

@@ -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');
});
});

View File

@@ -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
});
}
}

View File

@@ -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);
});
});

View File

@@ -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.
}
}
}

View File

@@ -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()"

View File

@@ -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"

View File

@@ -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

View File

@@ -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;