feat: Android APP V1 - Experimental Alpha

This commit is contained in:
2026-06-05 07:40:25 +02:00
parent bf4e6891d1
commit 9a1305f976
179 changed files with 8031 additions and 120 deletions
@@ -0,0 +1,503 @@
import { Injectable, inject } from '@angular/core';
import {
DELETED_MESSAGE_CONTENT,
type BanEntry,
type Message,
type Reaction,
type Room,
type User
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import {
attachmentToValues,
banToValues,
customEmojiToValues,
messageToRow,
reactionToValues,
roomToRow,
rowToAttachment,
rowToCustomEmoji,
rowToMessage,
rowToRoom,
rowToUser,
userToRow,
type MessageRow,
type RoomRow,
type UserRow
} from '../mobile/logic/mobile-sqlite-row-mapper.rules';
import { MobileSqliteConnectionService } from '../mobile/services/mobile-sqlite-connection.service';
import type { RoomMessageStats } from './database.service';
/**
* SQLite-backed database service for Capacitor native shells.
*
* Mirrors the {@link BrowserDatabaseService} API using `@capacitor-community/sqlite`.
*/
@Injectable({ providedIn: 'root' })
export class CapacitorDatabaseService {
private readonly connection = inject(MobileSqliteConnectionService);
async initialize(): Promise<void> {
await this.connection.initialize();
}
async saveMessage(message: Message): Promise<void> {
const store = await this.connection.getStore();
const row = messageToRow(message);
await store.run(
`INSERT OR REPLACE INTO messages (
id, roomId, ownerUserId, channelId, senderId, senderName, content,
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.roomId,
row.ownerUserId ?? null,
row.channelId ?? null,
row.senderId,
row.senderName,
row.content,
row.timestamp,
row.editedAt ?? null,
row.isDeleted,
row.replyToId ?? null,
row.linkMetadata ?? null,
row.kind ?? null,
row.systemEvent ?? null
]
);
}
async getMessages(
roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
): Promise<Message[]> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>(`
SELECT * FROM messages
WHERE roomId = ?
${beforeTimestamp !== undefined ? 'AND timestamp < ?' : ''}
ORDER BY timestamp ASC
`, beforeTimestamp !== undefined ? [roomId, beforeTimestamp] : [roomId]);
const scopedRows = channelId
? rows.filter((row) => (row.channelId || 'general') === channelId)
: rows;
const endIndex = Math.max(scopedRows.length - offset, 0);
const startIndex = Math.max(endIndex - limit, 0);
const slice = scopedRows.slice(startIndex, endIndex);
return this.hydrateMessages(slice.map((row) => rowToMessage(row)));
}
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>(
'SELECT * FROM messages WHERE roomId = ? AND timestamp > ? ORDER BY timestamp ASC',
[roomId, sinceTimestamp]
);
return this.hydrateMessages(rows.map((row) => rowToMessage(row)));
}
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
const store = await this.connection.getStore();
const rows = await store.query<{ count: number; lastUpdated: number }>(
`SELECT COUNT(*) as count, MAX(COALESCE(editedAt, timestamp, 0)) as lastUpdated
FROM messages WHERE roomId = ?`,
[roomId]
);
return {
count: Number(rows[0]?.count ?? 0),
lastUpdated: Number(rows[0]?.lastUpdated ?? 0)
};
}
async deleteMessage(messageId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM messages WHERE id = ?', [messageId]);
}
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const existing = await this.getMessageById(messageId);
if (existing) {
await this.saveMessage({ ...existing, ...updates });
}
}
async getMessageById(messageId: string): Promise<Message | null> {
const store = await this.connection.getStore();
const rows = await store.query<MessageRow>('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
const row = rows[0];
if (!row) {
return null;
}
const messages = await this.hydrateMessages([rowToMessage(row)]);
return messages[0] ?? null;
}
async clearRoomMessages(roomId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
}
async saveReaction(reaction: Reaction): Promise<void> {
const store = await this.connection.getStore();
const existing = await store.query<Reaction>(
'SELECT * FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ? LIMIT 1',
[
reaction.messageId,
reaction.userId,
reaction.emoji
]
);
if (existing.length === 0) {
await store.run(
'INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
reactionToValues(reaction)
);
}
}
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const store = await this.connection.getStore();
await store.run(
'DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
[
messageId,
userId,
emoji
]
);
}
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
const store = await this.connection.getStore();
return store.query<Reaction>(
'SELECT * FROM reactions WHERE messageId = ? ORDER BY timestamp ASC',
[messageId]
);
}
async saveUser(user: User): Promise<void> {
const store = await this.connection.getStore();
const row = userToRow(user);
await store.run(
`INSERT OR REPLACE INTO users (
id, oderId, username, displayName, description, profileUpdatedAt,
avatarUrl, avatarHash, avatarMime, avatarUpdatedAt, status, role,
joinedAt, peerId, isOnline, isAdmin, isRoomOwner, voiceState,
screenShareState, homeSignalServerUrl
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.oderId ?? null,
row.username ?? null,
row.displayName ?? null,
row.description ?? null,
row.profileUpdatedAt ?? null,
row.avatarUrl ?? null,
row.avatarHash ?? null,
row.avatarMime ?? null,
row.avatarUpdatedAt ?? null,
row.status ?? null,
row.role ?? null,
row.joinedAt ?? null,
row.peerId ?? null,
row.isOnline,
row.isAdmin,
row.isRoomOwner,
row.voiceState ?? null,
row.screenShareState ?? null,
row.homeSignalServerUrl ?? null
]
);
}
async getUser(userId: string): Promise<User | null> {
const store = await this.connection.getStore();
const rows = await store.query<UserRow>('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
return rows[0] ? rowToUser(rows[0]) : null;
}
async getCurrentUser(): Promise<User | null> {
const userId = await this.getCurrentUserId();
return userId ? this.getUser(userId) : null;
}
async getCurrentUserId(): Promise<string | null> {
const store = await this.connection.getStore();
const rows = await store.query<{ value: string }>(
"SELECT value FROM meta WHERE key = 'currentUserId' LIMIT 1"
);
return rows[0]?.value?.trim() || null;
}
async setCurrentUserId(userId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)",
[userId]
);
if (getStoredCurrentUserId() !== userId) {
await this.connection.initialize();
}
}
async getUsersByRoom(_roomId: string): Promise<User[]> {
const store = await this.connection.getStore();
const rows = await store.query<UserRow>('SELECT * FROM users');
return rows.map(rowToUser);
}
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const existing = await this.getUser(userId);
if (existing) {
await this.saveUser({ ...existing, ...updates });
}
}
async saveRoom(room: Room): Promise<void> {
const store = await this.connection.getStore();
const row = roomToRow(room);
await store.run(
`INSERT OR REPLACE INTO rooms (
id, name, description, topic, hostId, password, hasPassword, isPrivate,
createdAt, userCount, maxUsers, icon, iconUpdatedAt, slowModeInterval,
sourceId, sourceName, sourceUrl
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.name,
row.description ?? null,
row.topic ?? null,
row.hostId,
row.password ?? null,
row.hasPassword,
row.isPrivate,
row.createdAt,
row.userCount,
row.maxUsers ?? null,
row.icon ?? null,
row.iconUpdatedAt ?? null,
row.slowModeInterval,
row.sourceId ?? null,
row.sourceName ?? null,
row.sourceUrl ?? null
]
);
}
async getRoom(roomId: string): Promise<Room | null> {
const store = await this.connection.getStore();
const rows = await store.query<RoomRow>('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
return rows[0] ? rowToRoom(rows[0]) : null;
}
async getAllRooms(): Promise<Room[]> {
const store = await this.connection.getStore();
const rows = await store.query<RoomRow>('SELECT * FROM rooms ORDER BY createdAt ASC');
return rows.map(rowToRoom);
}
async deleteRoom(roomId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM rooms WHERE id = ?', [roomId]);
await this.clearRoomMessages(roomId);
}
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const existing = await this.getRoom(roomId);
if (existing) {
await this.saveRoom({ ...existing, ...updates });
}
}
async saveBan(ban: BanEntry): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO bans (
oderId, roomId, userId, bannedBy, displayName, reason, expiresAt, timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
banToValues(ban)
);
}
async removeBan(oderId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
}
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const store = await this.connection.getStore();
const now = Date.now();
const rows = await store.query<BanEntry>(
'SELECT * FROM bans WHERE roomId = ?',
[roomId]
);
return rows.filter((ban) => !ban.expiresAt || ban.expiresAt > now);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.oderId === userId);
}
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO attachments (
id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
attachmentToValues(attachment)
);
}
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>(
'SELECT * FROM attachments WHERE messageId = ?',
[messageId]
);
return rows.map(rowToAttachment);
}
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>('SELECT * FROM attachments');
return rows.map(rowToAttachment);
}
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
const store = await this.connection.getStore();
await store.run(
`INSERT OR REPLACE INTO custom_emojis (
id, name, creatorUserId, dataUrl, hash, mime, size, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
customEmojiToValues(emoji)
);
}
async getCustomEmojis(): Promise<CustomEmoji[]> {
const store = await this.connection.getStore();
const rows = await store.query<Parameters<typeof rowToCustomEmoji>[0]>('SELECT * FROM custom_emojis');
return rows.map(rowToCustomEmoji);
}
async deleteCustomEmoji(emojiId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM custom_emojis WHERE id = ?', [emojiId]);
}
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const store = await this.connection.getStore();
await store.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
}
async clearAllData(): Promise<void> {
const store = await this.connection.getStore();
const tables = [
'messages',
'users',
'rooms',
'reactions',
'bans',
'attachments',
'custom_emojis',
'meta',
'push_device_tokens'
];
for (const table of tables) {
await store.run(`DELETE FROM ${table}`);
}
}
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
if (messages.length === 0) {
return [];
}
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
return messages.map((message) => this.normaliseMessage({
...message,
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
}));
}
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
const reactionsByMessageId = new Map<string, Reaction[]>();
if (messageIdSet.size === 0) {
return reactionsByMessageId;
}
const store = await this.connection.getStore();
const allReactions = await store.query<Reaction>('SELECT * FROM reactions');
for (const reaction of allReactions) {
if (!messageIdSet.has(reaction.messageId)) {
continue;
}
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
reactions.push(reaction);
reactionsByMessageId.set(reaction.messageId, reactions);
}
for (const reactions of reactionsByMessageId.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return reactionsByMessageId;
}
private normaliseMessage(message: Message): Message {
if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message, reactions: [] };
}
return message;
}
}
@@ -0,0 +1,25 @@
import {
describe,
expect,
it
} from 'vitest';
import { resolveDatabaseBackend } from './database-backend.rules';
describe('database-backend.rules', () => {
it('routes Electron to the IPC SQLite backend', () => {
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: false })).toBe('electron');
});
it('routes Capacitor native shells to SQLite instead of IndexedDB', () => {
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: true })).toBe('capacitor-sqlite');
});
it('routes plain browser shells to IndexedDB', () => {
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: false })).toBe('browser');
});
it('prefers Electron when both Electron and Capacitor flags are set', () => {
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: true })).toBe('electron');
});
});
@@ -0,0 +1,17 @@
export type DatabaseBackendKind = 'electron' | 'capacitor-sqlite' | 'browser';
/** Selects the persistence backend for the current runtime shell. */
export function resolveDatabaseBackend(input: {
isElectron: boolean;
isCapacitor: boolean;
}): DatabaseBackendKind {
if (input.isElectron) {
return 'electron';
}
if (input.isCapacitor) {
return 'capacitor-sqlite';
}
return 'browser';
}
@@ -12,6 +12,7 @@ import {
import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { DatabaseService } from './database.service';
import { ElectronDatabaseService } from './electron-database.service';
@@ -20,6 +21,10 @@ describe('DatabaseService', () => {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
};
let capacitorDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
};
let electronDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
@@ -30,18 +35,23 @@ describe('DatabaseService', () => {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
capacitorDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
electronDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
};
});
function createService(): DatabaseService {
function createService(platform: Pick<PlatformService, 'isBrowser' | 'isElectron' | 'isCapacitor'>): DatabaseService {
const injector = Injector.create({
providers: [
DatabaseService,
{ provide: PlatformService, useValue: { isBrowser: true, isElectron: false } },
{ provide: PlatformService, useValue: platform },
{ provide: BrowserDatabaseService, useValue: browserDatabase },
{ provide: CapacitorDatabaseService, useValue: capacitorDatabase },
{ provide: ElectronDatabaseService, useValue: electronDatabase }
]
});
@@ -49,13 +59,35 @@ describe('DatabaseService', () => {
return runInInjectionContext(injector, () => injector.get(DatabaseService));
}
it('initializes the selected backend before the first delegated read', async () => {
const service = createService();
it('initializes the browser backend before the first delegated read', async () => {
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
expect(service.isReady()).toBe(true);
});
});
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });
await service.getBansForRoom('room-1');
expect(capacitorDatabase.initialize).toHaveBeenCalledTimes(1);
expect(capacitorDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.initialize).not.toHaveBeenCalled();
});
it('routes Electron shells to the IPC SQLite backend', async () => {
const service = createService({ isBrowser: false, isElectron: true, isCapacitor: false });
await service.getBansForRoom('room-1');
expect(electronDatabase.initialize).toHaveBeenCalledTimes(1);
expect(electronDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.initialize).not.toHaveBeenCalled();
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
});
});
@@ -14,6 +14,8 @@ import {
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { PlatformService } from '../../core/platform';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { resolveDatabaseBackend } from './database-backend.rules';
import { ElectronDatabaseService } from './electron-database.service';
export interface RoomMessageStats {
@@ -26,6 +28,7 @@ export interface RoomMessageStats {
* storage backend based on the runtime platform.
*
* - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process).
* - Capacitor -> native SQLite via {@link CapacitorDatabaseService}.
* - Browser -> IndexedDB via {@link BrowserDatabaseService}.
*
* All consumers inject `DatabaseService`; the underlying storage engine
@@ -35,6 +38,7 @@ export interface RoomMessageStats {
export class DatabaseService {
private readonly platform = inject(PlatformService);
private readonly browserDb = inject(BrowserDatabaseService);
private readonly capacitorDb = inject(CapacitorDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
private initializationPromise: Promise<void> | null = null;
@@ -43,7 +47,20 @@ export class DatabaseService {
/** The active storage backend for the current platform. */
private get backend() {
return this.platform.isBrowser ? this.browserDb : this.electronDb;
const backendKind = resolveDatabaseBackend({
isElectron: this.platform.isElectron,
isCapacitor: this.platform.isCapacitor
});
if (backendKind === 'electron') {
return this.electronDb;
}
if (backendKind === 'capacitor-sqlite') {
return this.capacitorDb;
}
return this.browserDb;
}
/** Initialise the platform-specific database. */