diff --git a/src/BuildErrorSanitizer.php b/src/BuildErrorSanitizer.php
new file mode 100644
index 0000000..5825574
--- /dev/null
+++ b/src/BuildErrorSanitizer.php
@@ -0,0 +1,28 @@
+]+#i', '***', $out) ?? $out;
+ $out = preg_replace('#git://[^\s\'"<>]+#i', '***', $out) ?? $out;
+ $out = preg_replace('#ssh://[^\s\'"<>]+#i', '***', $out) ?? $out;
+ $out = preg_replace(
+ '/\b(password|passwd|pass|token|secret|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+/i',
+ '$1=***',
+ $out
+ ) ?? $out;
+ $out = preg_replace(
+ '/(?:\/[^\s]+)?\/(?:\.env|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|env))[^\s]*/i',
+ '***',
+ $out
+ ) ?? $out;
+ $out = preg_replace('#/var/www/[^\s\'"]+#', '***', $out) ?? $out;
+ $out = preg_replace('#/home/[^\s\'"]+#', '***', $out) ?? $out;
+ return trim(preg_replace('/\s{2,}/', ' ', $out) ?? $out);
+ }
+}
diff --git a/src/BuildNotifier.php b/src/BuildNotifier.php
new file mode 100644
index 0000000..18b40de
--- /dev/null
+++ b/src/BuildNotifier.php
@@ -0,0 +1,236 @@
+ $build */
+ public static function onFailure(int $buildId, array $build, string $errorMessage): void {
+ $params = json_decode((string) ($build['params_json'] ?? '{}'), true);
+ if (!is_array($params)) {
+ $params = [];
+ }
+ $trigger = (string) ($params['trigger_source'] ?? 'manual');
+ $safeError = BuildErrorSanitizer::sanitize($errorMessage);
+ $code = (string) ($build['build_code'] ?? ('#' . $buildId));
+ $ref = (string) ($build['git_ref'] ?? $build['branch'] ?? '—');
+ $basePath = rtrim((string) cfg('base_path', '/app/androidcast_project/build'), '/');
+ $detailUrl = self::publicBaseUrl() . $basePath . '/?view=build&id=' . $buildId;
+ $subject = 'Android Cast build failed: ' . $code;
+ $bodyText = "Build {$code} failed (ref: {$ref}).\n\n"
+ . $safeError . "\n\n"
+ . "Details: {$detailUrl}\n";
+
+ if (in_array($trigger, ['autobuild', 'agent', 'ci'], true)) {
+ self::notifyAdmin($subject, $bodyText, $code, $safeError, $detailUrl);
+ return;
+ }
+
+ if (empty($params['notify_on_fail'])) {
+ return;
+ }
+
+ $channel = (string) ($params['notify_channel'] ?? 'email');
+ $userId = (int) ($build['created_by_user_id'] ?? 0);
+ $username = self::usernameFor($userId);
+ $userPrefix = $username !== '' ? "User {$username}: " : '';
+
+ if ($channel === 'telegram' || $channel === 'both') {
+ self::sendTelegram(
+ $userPrefix . $subject,
+ $bodyText,
+ self::telegramConfig()
+ );
+ }
+ if ($channel === 'email' || $channel === 'both') {
+ $to = self::emailForUser($userId);
+ if ($to !== '') {
+ self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig());
+ }
+ }
+ }
+
+ private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void {
+ $tg = self::telegramConfig();
+ if ($tg['token'] !== '' && $tg['chat_id'] !== '') {
+ self::sendTelegram($subject, $bodyText, $tg);
+ }
+ $admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com');
+ if ($admin !== '') {
+ $html = '
' . h($code) . ' failed.
'
+ . '' . h($safeError) . '
'
+ . 'Open build log
';
+ self::sendPlainEmail($admin, $subject, $html, self::smtpConfig());
+ }
+ }
+
+ /** @return array{token:string,chat_id:string} */
+ private static function telegramConfig(): array {
+ $token = trim((string) cfg('build.notify.telegram_token', ''));
+ $chatId = trim((string) cfg('build.notify.telegram_chat_id', ''));
+ if ($token === '' || $chatId === '') {
+ $bc = self::loadBroadcastConfig();
+ $token = $token !== '' ? $token : trim((string) ($bc['telegram_token'] ?? ''));
+ $chatId = $chatId !== '' ? $chatId : trim((string) ($bc['telegram_chat_id'] ?? ''));
+ }
+ return ['token' => $token, 'chat_id' => $chatId];
+ }
+
+ /** @return array */
+ private static function smtpConfig(): array {
+ $mail = cfg('mail', []);
+ if (is_array($mail) && !empty($mail['smtp'])) {
+ $smtp = $mail['smtp'];
+ return [
+ 'host' => (string) ($smtp['host'] ?? 'smtp.gmail.com'),
+ 'port' => (int) ($smtp['port'] ?? 587),
+ 'user' => (string) ($smtp['user'] ?? $smtp['username'] ?? ''),
+ 'pass' => (string) ($smtp['password'] ?? $smtp['pass'] ?? ''),
+ 'from' => (string) ($mail['from'] ?? $smtp['user'] ?? ''),
+ 'from_name' => (string) ($mail['from_name'] ?? 'AndroidCast Builder'),
+ ];
+ }
+ $bc = self::loadBroadcastConfig();
+ return [
+ 'host' => (string) ($bc['smtp_host'] ?? 'smtp.gmail.com'),
+ 'port' => (int) ($bc['smtp_port'] ?? 587),
+ 'user' => (string) ($bc['smtp_user'] ?? ''),
+ 'pass' => (string) ($bc['smtp_password'] ?? ''),
+ 'from' => (string) ($bc['smtp_from'] ?? $bc['smtp_user'] ?? ''),
+ 'from_name' => (string) ($bc['smtp_from_name'] ?? 'AndroidCast Builder'),
+ ];
+ }
+
+ /** @return array */
+ private static function loadBroadcastConfig(): array {
+ static $cached = null;
+ if (is_array($cached)) {
+ return $cached;
+ }
+ $path = (string) cfg(
+ 'build.notify.broadcast_config',
+ '/var/www/ac/broadcast/config/config.php'
+ );
+ if ($path !== '' && is_file($path)) {
+ $cfg = require $path;
+ $cached = is_array($cfg) ? $cfg : [];
+ return $cached;
+ }
+ $cached = [];
+ return $cached;
+ }
+
+ private static function publicBaseUrl(): string {
+ $host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? 'apps.f0xx.org';
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+ || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
+ ? 'https' : 'http';
+ return $scheme . '://' . explode(',', (string) $host)[0];
+ }
+
+ private static function emailForUser(int $userId): string {
+ if ($userId <= 0) {
+ return '';
+ }
+ $stmt = Database::pdo()->prepare(
+ 'SELECT email_normalized, username FROM users WHERE id = ? LIMIT 1'
+ );
+ $stmt->execute([$userId]);
+ $row = $stmt->fetch();
+ if (!$row) {
+ return '';
+ }
+ $email = trim((string) ($row['email_normalized'] ?? ''));
+ if ($email !== '' && str_contains($email, '@')) {
+ return $email;
+ }
+ return '';
+ }
+
+ private static function usernameFor(int $userId): string {
+ if ($userId <= 0) {
+ return '';
+ }
+ $stmt = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
+ $stmt->execute([$userId]);
+ $row = $stmt->fetch();
+ return $row ? (string) ($row['username'] ?? '') : '';
+ }
+
+ /** @param array{token:string,chat_id:string} $tg */
+ private static function sendTelegram(string $title, string $body, array $tg): void {
+ if ($tg['token'] === '' || $tg['chat_id'] === '') {
+ return;
+ }
+ $text = '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n\n"
+ . htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $payload = http_build_query([
+ 'chat_id' => $tg['chat_id'],
+ 'text' => $text,
+ 'parse_mode' => 'HTML',
+ 'disable_web_page_preview' => '1',
+ ]);
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
+ 'content' => $payload,
+ 'timeout' => 15,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ @file_get_contents(
+ 'https://api.telegram.org/bot' . $tg['token'] . '/sendMessage',
+ false,
+ $ctx
+ );
+ }
+
+ /** @param array $smtp */
+ private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
+ if ($to === '' || empty($smtp['user']) || empty($smtp['pass'])) {
+ return;
+ }
+ $host = (string) ($smtp['host'] ?? 'smtp.gmail.com');
+ $port = (int) ($smtp['port'] ?? 587);
+ $from = (string) ($smtp['from'] ?? $smtp['user']);
+ $fromName = (string) ($smtp['from_name'] ?? 'AndroidCast Builder');
+ $boundary = bin2hex(random_bytes(8));
+ $headers = [
+ "From: {$fromName} <{$from}>",
+ "To: {$to}",
+ 'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=',
+ 'MIME-Version: 1.0',
+ "Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
+ ];
+ $plain = strip_tags(str_replace(['
', '
', '
'], "\n", $htmlBody));
+ $body = "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$plain}\r\n"
+ . "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"
+ . "--{$boundary}--";
+ $sock = @fsockopen("tls://{$host}", $port, $errno, $errstr, 15);
+ if (!$sock) {
+ return;
+ }
+ $read = static fn(): string => (string) fgets($sock, 1024);
+ $send = static function (string $cmd) use ($sock): void {
+ fwrite($sock, $cmd . "\r\n");
+ };
+ $read();
+ $send('EHLO builder.local');
+ $read();
+ $send('AUTH LOGIN');
+ $read();
+ $send(base64_encode((string) $smtp['user']));
+ $read();
+ $send(base64_encode((string) $smtp['pass']));
+ $read();
+ $send("MAIL FROM:<{$from}>");
+ $read();
+ $send("RCPT TO:<{$to}>");
+ $read();
+ $send('DATA');
+ $read();
+ fwrite($sock, implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n");
+ $send('QUIT');
+ fclose($sock);
+ }
+}
diff --git a/src/BuildRunner.php b/src/BuildRunner.php
index f10f56f..982252c 100644
--- a/src/BuildRunner.php
+++ b/src/BuildRunner.php
@@ -94,6 +94,9 @@ YAML;
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
+ if (!isset($params['trigger_source'])) {
+ $params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
+ }
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
@@ -138,6 +141,8 @@ YAML;
. ' -C ' . escapeshellarg($repo)
. ' remote get-url origin 2>/dev/null'
));
+ $mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
+ $scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
@@ -146,6 +151,9 @@ YAML;
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
+ 'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
+ 'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
+ 'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
@@ -185,15 +193,20 @@ YAML;
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
+ $safe = BuildErrorSanitizer::sanitize($message);
if ($logPath) {
- file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
+ file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
- 'error_message' => substr($message, 0, 512),
+ 'error_message' => substr($safe, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
+ $build = BuildRepository::getById($id);
+ if ($build) {
+ BuildNotifier::onFailure($id, $build, $safe);
+ }
}
public static function stopBuild(int $id): bool {
@@ -276,6 +289,14 @@ YAML;
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
+ $build = BuildRepository::getById($buildId);
+ if ($build) {
+ $err = (string) ($build['error_message'] ?? '');
+ $started = (string) ($build['started_at'] ?? '');
+ if ($started === '' && str_contains($err, 'Docker daemon not available')) {
+ return 'docker-setup';
+ }
+ }
$dir = self::artifactDir($buildId);
$logTail = '';
$logPath = $dir . '/build.log';
@@ -456,7 +477,7 @@ YAML;
if (strlen($summary) > 480) {
$summary = '…' . substr($summary, -477);
}
- return 'Build runner exited unexpectedly: ' . $summary;
+ return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary);
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
@@ -508,13 +529,27 @@ YAML;
$versionApp = $info['gitSha'] ?? null;
}
}
- BuildRepository::update($buildId, [
+ $update = [
'status' => $status,
'phase' => 'done',
'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
'version_app' => $versionApp,
'finished_at' => gmdate('Y-m-d H:i:s'),
- ]);
+ ];
+ if ($status === 'failed') {
+ $update['error_message'] = substr(
+ BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
+ 0,
+ 512
+ );
+ }
+ BuildRepository::update($buildId, $update);
+ if ($status === 'failed') {
+ $build = BuildRepository::getById($buildId);
+ if ($build) {
+ BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
+ }
+ }
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
@@ -533,19 +568,86 @@ YAML;
return;
}
$build = BuildRepository::getById($buildId);
- $channel = $build['ota_channel'] ?? 'staging';
+ $channel = (string) ($build['ota_channel'] ?? 'staging');
$dest = $mount . '/v0';
shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
- if ($channel !== '' && $channel !== 'staging') {
- $channelJson = $src . '/ota/channel/' . $channel . '.json';
- if (is_file($channelJson)) {
- shell_exec(
- 'cp -a '
- . escapeshellarg($channelJson)
- . ' '
- . escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
- );
+ $channelJson = $src . '/ota/channel/' . $channel . '.json';
+ if (is_file($channelJson)) {
+ shell_exec(
+ 'mkdir -p '
+ . escapeshellarg($dest . '/ota/channel')
+ . ' && cp -a '
+ . escapeshellarg($channelJson)
+ . ' '
+ . escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
+ );
+ }
+ self::publishBrowserApk($src, $channel);
+ }
+
+ /** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
+ private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
+ $downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
+ if ($downloads === '') {
+ $parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/'));
+ $downloads = $parent !== '' && $parent !== '.'
+ ? $parent . '/downloads'
+ : '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads';
+ }
+ if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) {
+ return;
+ }
+ $apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel);
+ if ($apkPath === null || !is_file($apkPath)) {
+ return;
+ }
+ $latest = $downloads . '/android_cast-latest.apk';
+ $versioned = $downloads . '/' . basename($apkPath);
+ @copy($apkPath, $latest);
+ @copy($apkPath, $versioned);
+ @chown($latest, 'nginx');
+ @chgrp($latest, 'nginx');
+ @chown($versioned, 'nginx');
+ @chgrp($versioned, 'nginx');
+ }
+
+ private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string {
+ $channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json';
+ if (is_file($channelFile)) {
+ $channelData = json_decode((string) file_get_contents($channelFile), true);
+ $manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : '';
+ if ($manifestUrl !== '') {
+ $manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir);
+ if ($manifestPath !== null && is_file($manifestPath)) {
+ $manifest = json_decode((string) file_get_contents($manifestPath), true);
+ if (is_array($manifest)) {
+ $browser = (string) ($manifest['browserApkUrl'] ?? '');
+ if ($browser !== '') {
+ $local = self::otaUrlToLocalPath($browser, $otaV0Dir);
+ if ($local !== null && is_file($local)) {
+ return $local;
+ }
+ }
+ }
+ }
}
}
+ $glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: [];
+ if ($glob !== []) {
+ usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
+ return $glob[0];
+ }
+ return null;
+ }
+
+ private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string {
+ $path = (string) parse_url($url, PHP_URL_PATH);
+ $prefix = '/v0/ota/';
+ if (!str_starts_with($path, $prefix)) {
+ return null;
+ }
+ $rel = substr($path, strlen($prefix));
+ $local = $otaV0Dir . '/ota/' . $rel;
+ return is_file($local) ? $local : null;
}
}