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

View File

@@ -0,0 +1,283 @@
import fs from 'fs';
import http2 from 'http2';
import path from 'path';
import {
createPrivateKey,
createSign,
sign
} from 'crypto';
import { getDataSource } from '../db';
import { DeviceTokenEntity } from '../entities/DeviceTokenEntity';
export interface PushNotificationPayload {
title: string;
body: string;
data?: Record<string, string>;
}
interface FcmServiceAccount {
project_id: string;
client_email: string;
private_key: string;
}
let cachedFcmAccessToken: { token: string; expiresAt: number } | null = null;
function readJsonFile(filePath: string): unknown {
const resolved = path.resolve(filePath);
const raw = fs.readFileSync(resolved, 'utf8');
return JSON.parse(raw);
}
function resolveFcmServiceAccount(): FcmServiceAccount | null {
const inlineJson = process.env.FCM_SERVICE_ACCOUNT_JSON?.trim();
if (inlineJson) {
return JSON.parse(inlineJson) as FcmServiceAccount;
}
const filePath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim();
if (filePath) {
return readJsonFile(filePath) as FcmServiceAccount;
}
return null;
}
function base64UrlEncode(value: string | Buffer): string {
return Buffer.from(value)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
async function getFcmAccessToken(serviceAccount: FcmServiceAccount): Promise<string> {
if (cachedFcmAccessToken && cachedFcmAccessToken.expiresAt > Date.now() + 60_000) {
return cachedFcmAccessToken.token;
}
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claimSet = base64UrlEncode(JSON.stringify({
iss: serviceAccount.client_email,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600
}));
const unsigned = `${header}.${claimSet}`;
const signer = createSign('RSA-SHA256');
signer.update(unsigned);
signer.end();
const signature = base64UrlEncode(signer.sign(serviceAccount.private_key));
const assertion = `${unsigned}.${signature}`;
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion
})
});
if (!response.ok) {
throw new Error(`FCM OAuth token request failed with status ${response.status}`);
}
const payload = await response.json() as { access_token: string; expires_in: number };
cachedFcmAccessToken = {
token: payload.access_token,
expiresAt: Date.now() + payload.expires_in * 1000
};
return payload.access_token;
}
async function sendFcmMessage(token: string, notification: PushNotificationPayload): Promise<void> {
const serviceAccount = resolveFcmServiceAccount();
if (!serviceAccount) {
console.warn('[push] FCM credentials are not configured; skipping Android dispatch');
return;
}
const accessToken = await getFcmAccessToken(serviceAccount);
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
token,
notification: {
title: notification.title,
body: notification.body
},
data: notification.data ?? {}
}
})
}
);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`FCM dispatch failed (${response.status}): ${errorBody}`);
}
}
function resolveApnsConfig(): {
key: string;
keyId: string;
teamId: string;
bundleId: string;
} | null {
const keyPath = process.env.APNS_KEY_PATH?.trim();
const keyId = process.env.APNS_KEY_ID?.trim();
const teamId = process.env.APNS_TEAM_ID?.trim();
const bundleId = process.env.APNS_BUNDLE_ID?.trim() || 'com.metoyou.app';
if (!keyPath || !keyId || !teamId) {
return null;
}
return {
key: fs.readFileSync(path.resolve(keyPath), 'utf8'),
keyId,
teamId,
bundleId
};
}
function buildApnsJwt(config: { key: string; keyId: string; teamId: string }): string {
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(JSON.stringify({ alg: 'ES256', kid: config.keyId }));
const claims = base64UrlEncode(JSON.stringify({ iss: config.teamId, iat: now }));
const unsigned = `${header}.${claims}`;
const privateKey = createPrivateKey(config.key);
const signature = sign('sha256', Buffer.from(unsigned), {
key: privateKey,
dsaEncoding: 'ieee-p1363'
});
return `${unsigned}.${base64UrlEncode(signature)}`;
}
async function sendApnsMessage(deviceToken: string, notification: PushNotificationPayload): Promise<void> {
const config = resolveApnsConfig();
if (!config) {
console.warn('[push] APNs credentials are not configured; skipping iOS dispatch');
return;
}
const host = process.env.APNS_USE_SANDBOX === 'true'
? 'api.sandbox.push.apple.com'
: 'api.push.apple.com';
const jwt = buildApnsJwt(config);
const payload = JSON.stringify({
aps: {
alert: {
title: notification.title,
body: notification.body
},
sound: 'default'
},
...notification.data
});
await new Promise<void>((resolve, reject) => {
const client = http2.connect(`https://${host}`);
const request = client.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
authorization: `bearer ${jwt}`,
'apns-topic': config.bundleId,
'apns-push-type': 'alert',
'content-type': 'application/json'
});
request.setEncoding('utf8');
request.on('response', (headers) => {
const status = Number(headers[':status'] ?? 0);
if (status >= 200 && status < 300) {
resolve();
return;
}
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
reject(new Error(`APNs dispatch failed (${status}): ${body}`));
});
});
request.on('error', reject);
request.end(payload);
client.on('error', reject);
request.on('close', () => client.close());
});
}
export async function upsertDeviceToken(input: {
userId: string;
platform: 'ios' | 'android';
token: string;
}): Promise<void> {
const repository = getDataSource().getRepository(DeviceTokenEntity);
const id = `${input.userId}:${input.platform}:${input.token}`;
const record = repository.create({
id,
userId: input.userId,
platform: input.platform,
token: input.token,
updatedAt: Date.now()
});
await repository.save(record);
}
export async function listDeviceTokensForUser(userId: string): Promise<DeviceTokenEntity[]> {
return getDataSource()
.getRepository(DeviceTokenEntity)
.find({ where: { userId } });
}
export async function dispatchPushToUser(
userId: string,
notification: PushNotificationPayload
): Promise<void> {
const tokens = await listDeviceTokensForUser(userId);
await Promise.all(tokens.map(async (record) => {
try {
if (record.platform === 'android') {
await sendFcmMessage(record.token, notification);
return;
}
await sendApnsMessage(record.token, notification);
} catch (error) {
console.error('[push] Failed to dispatch to token', {
userId,
platform: record.platform,
error
});
}
}));
}