Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7511f8bf | ||
|
|
cc2b8d3d33 | ||
|
|
48e8bc537d | ||
|
|
f808f2f535 | ||
|
|
aee78c930d |
@@ -0,0 +1,92 @@
|
||||
# Compila o APK do Android no GitHub e o devolve como artefato.
|
||||
#
|
||||
# O runner entra só como máquina de build, pelo mesmo motivo do desktop: a VPS
|
||||
# tem ~2 GB de RAM livres e roda a produção, então um Gradle com o SDK do
|
||||
# Android ali dentro arrisca derrubar as chamadas de quem está usando o app.
|
||||
#
|
||||
# O APK sai **sem assinatura**. A chave de assinatura fica na VPS e não sobe
|
||||
# para cá: quem assina é /opt/ops/release-android.sh, depois de baixar este
|
||||
# artefato. Um APK assinado é instalável por qualquer um, e a chave é o que
|
||||
# prova que uma atualização veio de nós — ela não tem por que existir em duas
|
||||
# máquinas.
|
||||
name: Android APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ['android-v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
# cache/cache-dependency-path apontando para o npm deste pacote não é só
|
||||
# otimização: sem eles o setup-node lê o campo `packageManager` do
|
||||
# package.json da raiz, tenta usar pnpm — que este workflow não instala —
|
||||
# e falha antes de rodar qualquer coisa.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: packages/mobile/package-lock.json
|
||||
|
||||
# npm, não pnpm: packages/mobile fica fora do workspace de propósito
|
||||
# (ver pnpm-workspace.yaml). Ele tem o próprio package-lock.json.
|
||||
- name: Install Capacitor
|
||||
working-directory: packages/mobile
|
||||
run: npm ci
|
||||
|
||||
# Copia a config e os arquivos de public/ para dentro do projeto nativo.
|
||||
# Sem isso o APK sai com o que estiver commitado em assets/, que pode
|
||||
# estar velho — esses arquivos são gerados, não versionados.
|
||||
- name: Sync Capacitor
|
||||
working-directory: packages/mobile
|
||||
run: npx cap sync android
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ hashFiles('packages/mobile/android/**/*.gradle', 'packages/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: Build unsigned release APK
|
||||
working-directory: packages/mobile/android
|
||||
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
||||
|
||||
# O nome do arquivo muda conforme a versão do AGP (app-release-unsigned.apk
|
||||
# ou app-release.apk quando não há config de assinatura). Localiza em vez
|
||||
# de supor, e falha alto se nada saiu — um artefato vazio só apareceria
|
||||
# como erro lá na frente, na hora de assinar.
|
||||
- name: Collect APK
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p out
|
||||
found=$(find packages/mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
|
||||
[ -n "$found" ] || { echo "::error::o build não produziu APK nenhum"; exit 1; }
|
||||
cp "$found" out/resenhacord-unsigned.apk
|
||||
ls -l out/
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: resenhacord-apk-unsigned
|
||||
path: out/resenhacord-unsigned.apk
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,26 @@
|
||||
# Scripts de operação da instância
|
||||
|
||||
Rodam na VPS, em `/opt/ops`, onde os arquivos são links simbólicos para esta
|
||||
pasta — o repositório é a única cópia de verdade. Antes disso eles existiam só
|
||||
na VPS, sem versão nem backup: uma reinstalação do servidor levaria junto todo
|
||||
o processo de publicação.
|
||||
|
||||
| script | o que faz |
|
||||
| --- | --- |
|
||||
| `call-check.sh` | Diz se tem alguém em call. Roda antes de reiniciar o servidor. |
|
||||
| `release-desktop.sh` | Espelha o fork no GitHub e dispara a compilação dos instaladores de desktop. |
|
||||
| `release-android.sh` | Compila o APK no GitHub, assina na VPS e publica no Gitea. |
|
||||
|
||||
## O que estes scripts leem e não está aqui
|
||||
|
||||
Nada de segredo entra no repositório. Os arquivos abaixo ficam só na VPS, com
|
||||
permissão `600`:
|
||||
|
||||
- `/root/.github-token` — dispara e baixa os builds do GitHub.
|
||||
- `/root/.gitea-token` — publica os anexos das releases.
|
||||
- `/root/android-signing/` — a chave que assina o APK, mais a senha dela.
|
||||
|
||||
A chave do Android merece atenção especial: ela é o que prova ao Android que
|
||||
uma atualização veio de nós. Se for perdida, ninguém consegue atualizar por
|
||||
cima — todo mundo tem que desinstalar e instalar de novo, e não existe
|
||||
recuperação. Mantenha uma cópia fora da VPS.
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reports how many people are currently in a call on this instance.
|
||||
#
|
||||
# Asks LiveKit directly (ListRooms over its twirp API) rather than inferring
|
||||
# from the app: LiveKit is the authority on who is actually connected to media.
|
||||
# Prints the participant count and exits 0 when the instance is idle, 1 when
|
||||
# someone is in a call — so it can gate a deploy.
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE=${ENV_FILE:-/opt/backspace/.env}
|
||||
# `|| true`: under `set -o pipefail` a grep with no match aborts the script
|
||||
# before the check below, exiting 1 — which in this script's contract means
|
||||
# "someone is in a call" rather than "I could not tell".
|
||||
KEY=$(grep -E '^LIVEKIT_API_KEY=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
|
||||
SECRET=$(grep -E '^LIVEKIT_API_SECRET=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
|
||||
[ -n "$KEY" ] && [ -n "$SECRET" ] || { echo "LiveKit credentials not found in $ENV_FILE" >&2; exit 2; }
|
||||
|
||||
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
|
||||
|
||||
now=$(date +%s)
|
||||
header=$(printf '%s' '{"alg":"HS256","typ":"JWT"}' | b64url)
|
||||
payload=$(printf '{"iss":"%s","sub":"%s","nbf":%d,"exp":%d,"video":{"roomList":true}}' \
|
||||
"$KEY" "$KEY" "$now" "$((now + 60))" | b64url)
|
||||
sig=$(printf '%s.%s' "$header" "$payload" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
|
||||
jwt="$header.$payload.$sig"
|
||||
|
||||
resp=$(curl -sS --max-time 10 -w '\n%{http_code}' -X POST http://127.0.0.1:7880/twirp/livekit.RoomService/ListRooms \
|
||||
-H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' -d '{}') || {
|
||||
echo "LiveKit unreachable — refusing to report idle" >&2; exit 2; }
|
||||
|
||||
code=$(printf '%s' "$resp" | tail -n1)
|
||||
resp=$(printf '%s' "$resp" | sed '$d')
|
||||
|
||||
# A failed query returns no participant counts, which would read as "idle" and
|
||||
# green-light a deploy on top of a live call. Anything but a well-formed 200 is
|
||||
# treated as unknown, not as empty.
|
||||
if [ "$code" != "200" ] || ! printf '%s' "$resp" | grep -q '"rooms"'; then
|
||||
echo "LiveKit answered HTTP $code without a room list — status unknown, not deploying" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# numParticipants is omitted by protobuf JSON when zero, so absence means empty.
|
||||
total=$(printf '%s' "$resp" | grep -oE '"numParticipants":[0-9]+' | cut -d: -f2 | paste -sd+ - | bc 2>/dev/null || true)
|
||||
total=${total:-0}
|
||||
|
||||
if [ "$total" -gt 0 ]; then
|
||||
echo "EM CALL: $total participante(s) — nao deployar"
|
||||
exit 1
|
||||
fi
|
||||
echo "OCIOSO: ninguem em call"
|
||||
exit 0
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baixa o APK compilado no GitHub, assina aqui e publica no Gitea.
|
||||
#
|
||||
# A chave de assinatura fica nesta VPS e nao sobe para o GitHub. Ela e o que
|
||||
# prova que uma atualizacao veio de nos: um APK assinado com outra chave nao
|
||||
# instala por cima do instalado, o Android recusa. Por isso o runner entrega o
|
||||
# APK sem assinatura e a assinatura acontece aqui.
|
||||
#
|
||||
# Uso: release-android.sh [run_id]
|
||||
# sem run_id, dispara um build novo e espera terminar.
|
||||
set -euo pipefail
|
||||
|
||||
REPO=${REPO:-/opt/backspace}
|
||||
GH_REPO=${GH_REPO:-syncwrld/resenhacord}
|
||||
TOOLS=/opt/ops/android-tools/build-tools
|
||||
KEYSTORE=/root/android-signing/resenhacord.keystore
|
||||
PASSFILE=/root/android-signing/password.txt
|
||||
OUT=/opt/ops/android-out
|
||||
GITEA_API=https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
|
||||
|
||||
TOKEN=$(cat /root/.github-token)
|
||||
gh() { curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
|
||||
|
||||
# apksigner e um script que chama java, e esta VPS nao tem JDK instalado (nem
|
||||
# deve ter: ela roda a producao, nao compila nada). Roda num container, com as
|
||||
# ferramentas, a chave e a pasta de saida montadas. zipalign e binario nativo e
|
||||
# roda direto no host.
|
||||
apksigner() {
|
||||
docker run --rm \
|
||||
-v "$TOOLS":/tools:ro \
|
||||
-v /root/android-signing:/ks:ro \
|
||||
-v "$OUT":/out \
|
||||
eclipse-temurin:21-jdk /tools/apksigner "$@"
|
||||
}
|
||||
|
||||
for f in "$TOOLS/apksigner" "$TOOLS/zipalign" "$KEYSTORE" "$PASSFILE"; do
|
||||
[ -e "$f" ] || { echo "faltando: $f" >&2; exit 1; }
|
||||
done
|
||||
|
||||
RUN_ID=${1:-}
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "==> Disparando o build no GitHub"
|
||||
gh -o /dev/null -X POST \
|
||||
"https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/dispatches" \
|
||||
-d '{"ref":"main"}'
|
||||
sleep 10
|
||||
RUN_ID=$(gh "https://api.github.com/repos/$GH_REPO/actions/workflows/android.yml/runs?per_page=1" \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['workflow_runs'][0]['id'])")
|
||||
echo " run=$RUN_ID"
|
||||
|
||||
echo "==> Esperando (uns 4 min)"
|
||||
for _ in $(seq 1 90); do
|
||||
read -r status conclusion < <(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID" \
|
||||
| python3 -c "import sys,json;r=json.load(sys.stdin);print(r['status'],r['conclusion'])")
|
||||
[ "$status" = "completed" ] && break
|
||||
sleep 20
|
||||
done
|
||||
[ "${conclusion:-}" = "success" ] || { echo "build falhou: ${conclusion:-timeout}" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
echo "==> Baixando o APK sem assinatura"
|
||||
mkdir -p "$OUT"
|
||||
URL=$(gh "https://api.github.com/repos/$GH_REPO/actions/runs/$RUN_ID/artifacts" \
|
||||
| python3 -c "import sys,json;a=json.load(sys.stdin)['artifacts'];print(a[0]['archive_download_url'] if a else '')")
|
||||
[ -n "$URL" ] || { echo "o run $RUN_ID nao tem artefato" >&2; exit 1; }
|
||||
curl -sL -H "Authorization: Bearer $TOKEN" "$URL" -o "$OUT/apk.zip"
|
||||
rm -f "$OUT/resenhacord-unsigned.apk"
|
||||
unzip -o -q "$OUT/apk.zip" -d "$OUT"
|
||||
|
||||
VERSION=$(python3 -c "import json;print(json.load(open('$REPO/package.json'))['version'])")
|
||||
SIGNED="$OUT/Resenhacord-$VERSION.apk"
|
||||
|
||||
echo "==> Alinhando e assinando $VERSION"
|
||||
# --key-pass omitido de proposito: sem ele o apksigner reusa a senha do
|
||||
# keystore, que e a mesma. Passar o mesmo arquivo nas duas opcoes falha — ele le
|
||||
# os arquivos de senha em sequencia e bate no fim do arquivo na segunda leitura.
|
||||
# zipalign antes do apksigner, nao depois: o alinhamento move bytes dentro do
|
||||
# zip e invalidaria a assinatura se viesse por ultimo.
|
||||
"$TOOLS/zipalign" -p -f 4 "$OUT/resenhacord-unsigned.apk" "$OUT/aligned.apk"
|
||||
apksigner sign \
|
||||
--ks /ks/resenhacord.keystore --ks-pass file:/ks/password.txt \
|
||||
--ks-key-alias resenhacord \
|
||||
--out "/out/$(basename "$SIGNED")" /out/aligned.apk
|
||||
rm -f "$OUT/aligned.apk" "$OUT/apk.zip"
|
||||
|
||||
echo "==> Conferindo a assinatura"
|
||||
apksigner verify --print-certs "/out/$(basename "$SIGNED")" | head -5
|
||||
|
||||
if [ "${1:-}" = "--no-publish" ] || [ "${PUBLISH:-1}" = "0" ]; then
|
||||
echo "APK pronto: $SIGNED"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> Publicando no Gitea"
|
||||
GITEA_TOKEN=$(cat /root/.gitea-token 2>/dev/null || true)
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "sem /root/.gitea-token — APK pronto localmente em $SIGNED"
|
||||
exit 0
|
||||
fi
|
||||
api() { curl -s -w '\n%{http_code}' -X "$1" -H "Authorization: token $GITEA_TOKEN" "$GITEA_API$2" "${@:3}"; }
|
||||
body() { sed '$d' <<<"$1"; }
|
||||
code() { tail -n1 <<<"$1"; }
|
||||
|
||||
R=$(api GET "/releases/tags/latest")
|
||||
ID=$(body "$R" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||
[ -n "$ID" ] || { echo "release 'latest' nao encontrada no Gitea" >&2; exit 1; }
|
||||
|
||||
# Remove o APK anterior: dois anexos com o mesmo nome fazem o Gitea servir o
|
||||
# mais antigo, e quem baixasse pegaria a versao velha sem erro nenhum.
|
||||
api GET "/releases/$ID/assets" | sed '$d' \
|
||||
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
|
||||
| while read -r aid; do
|
||||
[ -n "$aid" ] && api DELETE "/releases/$ID/assets/$aid" >/dev/null && echo " removido anexo $aid"
|
||||
done
|
||||
|
||||
NAME=$(basename "$SIGNED")
|
||||
R=$(api POST "/releases/$ID/assets?name=$NAME" -F "attachment=@$SIGNED")
|
||||
[ "$(code "$R")" = "201" ] || { echo "falha no upload — HTTP $(code "$R"): $(body "$R")" >&2; exit 1; }
|
||||
echo "publicado: $NAME"
|
||||
echo "https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/$NAME"
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Espelha o fork no GitHub e dispara a compilacao dos instaladores.
|
||||
#
|
||||
# O GitHub e usado apenas como maquina de build: ele fornece runners Windows
|
||||
# prontos, e o modulo nativo do audio (WASAPI) so compila la. O Gitea continua
|
||||
# sendo o repositorio principal.
|
||||
set -euo pipefail
|
||||
|
||||
REPO=${REPO:-/opt/backspace}
|
||||
TAG=${1:-}
|
||||
cd "$REPO"
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "uso: $0 vX.Y.Z" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# `ssh -T git@github.com` always exits 1 (GitHub refuses shell access), and
|
||||
# under `pipefail` that status wins over grep's — so piping straight into the
|
||||
# `if` reported failure even on a working key. Capture, then test.
|
||||
SSH_PROBE="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -T git@github.com 2>&1 || true)"
|
||||
if ! printf '%s' "$SSH_PROBE" | grep -q "successfully authenticated"; then
|
||||
echo "GitHub ainda nao aceita a chave desta VPS." >&2
|
||||
echo "Adicione esta chave em https://github.com/settings/keys :" >&2
|
||||
cat ~/.ssh/id_ed25519.pub >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Espelhando main no GitHub"
|
||||
git push github main
|
||||
|
||||
echo "==> Marcando $TAG (e a tag que dispara o workflow release.yml)"
|
||||
git tag -f "$TAG"
|
||||
git push -f github "$TAG"
|
||||
|
||||
echo
|
||||
echo "Compilacao disparada. Acompanhe em:"
|
||||
echo " https://github.com/syncwrld/resenhacord/actions"
|
||||
echo "Os instaladores aparecem em Releases quando terminar (~15 min)."
|
||||
@@ -58,5 +58,15 @@ linux:
|
||||
- deb
|
||||
category: Network
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
# One-click, per-user install (%LOCALAPPDATA%), the way Discord ships.
|
||||
#
|
||||
# This is what makes an update silent: a per-user install needs no elevation,
|
||||
# so electron-updater can run the installer with /S and no UAC prompt breaks
|
||||
# the flow. The assisted installer we had before (oneClick: false) showed its
|
||||
# wizard on every update and offered an all-users install, which would then
|
||||
# demand elevation on each subsequent update.
|
||||
#
|
||||
# The trade is that a fresh install no longer asks where to put the app.
|
||||
oneClick: true
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: false
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildTrayMenuTemplate,
|
||||
buildAppMenuTemplate,
|
||||
type RecoveryState,
|
||||
RELEASES_URL,
|
||||
} from './recovery';
|
||||
import { migrateUserData } from './userDataMigration';
|
||||
|
||||
@@ -681,12 +682,25 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.on('install-update', () => {
|
||||
try {
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
autoUpdater.quitAndInstall();
|
||||
quitAndInstallSilently(autoUpdater);
|
||||
} catch {
|
||||
// Auto-updater not available
|
||||
}
|
||||
});
|
||||
|
||||
// The `update-downloaded` push is a one-shot: it reaches whatever page is
|
||||
// loaded at that instant, and nothing else. It is lost when the window is in
|
||||
// the tray (destroyed), when the check lands before the web app has mounted
|
||||
// its listener, and on every reload. Since `downloaded` also latches off
|
||||
// further polls, the banner could then never appear at all — which is why
|
||||
// pressing "check for updates" by hand looked like the only thing that
|
||||
// worked. The renderer now asks for the current state on mount instead of
|
||||
// relying solely on catching the event live.
|
||||
ipcMain.handle('get-update-status', () => {
|
||||
const s = recoveryStore.get();
|
||||
return { state: s.updateState, version: s.updateVersion };
|
||||
});
|
||||
|
||||
ipcMain.on('check-for-updates', () => {
|
||||
try {
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
@@ -811,6 +825,22 @@ function registerIpcHandlers(): void {
|
||||
|
||||
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Installs a staged update with no installer UI and relaunches the app.
|
||||
*
|
||||
* `quitAndInstall()` defaults to (isSilent=false, isForceRunAfter=false): on
|
||||
* Windows that runs the NSIS installer with its full wizard, so "restart to
|
||||
* update" meant clicking through a setup program. Silent mode passes /S and
|
||||
* reuses the recorded install location; isForceRunAfter brings the app back up
|
||||
* afterwards, which the wizard would otherwise leave to a checkbox.
|
||||
*
|
||||
* Both flags are inert on macOS and Linux, where the update is applied by
|
||||
* swapping files — no installer runs either way.
|
||||
*/
|
||||
function quitAndInstallSilently(au: { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void }): void {
|
||||
au.quitAndInstall(true, true);
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
try {
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
@@ -862,7 +892,7 @@ function initAutoUpdater(): void {
|
||||
showNotification(
|
||||
'Resenhacord update ready',
|
||||
`Click to restart and install version ${version}.`,
|
||||
() => autoUpdater.quitAndInstall(),
|
||||
() => quitAndInstallSilently(autoUpdater),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -885,7 +915,7 @@ function initAutoUpdater(): void {
|
||||
if (updateConfirmed) {
|
||||
mainWindow?.webContents.send('update-error', {
|
||||
message,
|
||||
releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest',
|
||||
releaseUrl: RELEASES_URL,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
checkForUpdates: () => {
|
||||
ipcRenderer.send('check-for-updates');
|
||||
},
|
||||
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
|
||||
getVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// Window focus
|
||||
|
||||
@@ -17,6 +17,15 @@ export type UpdateState =
|
||||
| 'downloaded'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Where a manual download lands when auto-update fails.
|
||||
*
|
||||
* The upstream GitHub repository this used to point at is private for this
|
||||
* fork, so the link 404'd for everyone who followed it. Releases are on the
|
||||
* fork's own Gitea, which is also where the updater feed lives.
|
||||
*/
|
||||
export const RELEASES_URL = 'https://git.resenha.website/devsyncwrld/backspace/releases';
|
||||
|
||||
export interface RecoveryState {
|
||||
mode: 'normal' | 'recovery';
|
||||
reason: { code: RecoveryReasonCode; detail: string } | null;
|
||||
@@ -419,7 +428,8 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
||||
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
|
||||
// Force-kill-fix: if user reaches recovery and clicks here, install
|
||||
// happens cleanly even if the on-quit hook would otherwise be bypassed.
|
||||
autoUpdaterRef?.quitAndInstall();
|
||||
// Silent + relaunch, matching the in-app banner: no installer wizard.
|
||||
autoUpdaterRef?.quitAndInstall(true, true);
|
||||
return;
|
||||
}
|
||||
case 'change-instance': {
|
||||
@@ -440,7 +450,7 @@ export function handleRecoveryAction(action: RecoveryAction): void {
|
||||
return;
|
||||
}
|
||||
case 'open-releases': {
|
||||
shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest');
|
||||
shell.openExternal(RELEASES_URL);
|
||||
return;
|
||||
}
|
||||
case 'quit': {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
|
||||
# Gerados por `cap sync` a cada build — versioná-los só criaria diffs ruidosos
|
||||
# e o risco de o APK sair com uma cópia velha.
|
||||
android/app/src/main/assets/public/
|
||||
android/app/src/main/assets/capacitor.config.json
|
||||
android/app/src/main/assets/capacitor.plugins.json
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,57 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "com.resenhacord.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.resenhacord.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
// Mantidos em sincronia com a versao do monorepo. O versionCode e o
|
||||
// numero que o Android usa para decidir se um APK e mais novo, e ele
|
||||
// precisa ser inteiro e sempre crescente: 2.1.0 -> 20100.
|
||||
versionCode 20100
|
||||
versionName "2.1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
O tipo "microphone" tem de bater com o que o servico passa para o
|
||||
startForeground e com a permissao FOREGROUND_SERVICE_MICROPHONE. A
|
||||
partir do Android 14 os tres precisam concordar, senao o sistema
|
||||
derruba o app na hora de entrar na call.
|
||||
-->
|
||||
<service
|
||||
android:name=".VoiceForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!--
|
||||
Voz e video. A WebView nao pede nada ao sistema sozinha: quando a pagina
|
||||
chama getUserMedia, o bridge do Capacitor intercepta o pedido e traduz
|
||||
para estas permissoes nativas. Sem declara-las aqui, a chamada falha com
|
||||
NotAllowedError e o microfone simplesmente nao liga.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Trocar entre alto-falante e fone durante a chamada. -->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- Android 13+ nao mostra notificacao nenhuma sem esta permissao. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!--
|
||||
Chamada com a tela apagada. O servico em primeiro plano impede o sistema
|
||||
de congelar o processo; o wake lock impede a CPU de dormir. Sem os dois, a
|
||||
WebView para de rodar o JavaScript e a call cai.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!--
|
||||
O aparelho precisa ter microfone; camera e opcional para nao excluir
|
||||
tablets sem camera frontal da lista de aparelhos compativeis.
|
||||
-->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final int REQ_NOTIFICATIONS = 100;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
// Plugins que moram no proprio app nao sao descobertos sozinhos, e o
|
||||
// registro tem de acontecer antes do super.onCreate — depois disso a
|
||||
// ponte ja foi montada e o plugin nao aparece para o JavaScript.
|
||||
registerPlugin(VoicePlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
requestNotificationPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* A partir do Android 13 a notificacao da chamada nao aparece sem esta
|
||||
* permissao. O servico continua rodando de qualquer forma — a chamada nao
|
||||
* cai se o usuario recusar —, mas ele fica sem nada visivel na barra, o que
|
||||
* so parece defeito. Por isso perguntamos, e seguimos com a recusa.
|
||||
*/
|
||||
private void requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED) return;
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQ_NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
/**
|
||||
* Mantem o app vivo enquanto ha uma chamada em andamento.
|
||||
*
|
||||
* Sem isto, trancar a tela derruba a call. O Android congela processos em
|
||||
* segundo plano — e uma WebView congelada para de rodar o JavaScript que
|
||||
* mantem a conexao do LiveKit de pe. Um servico em primeiro plano e a unica
|
||||
* coisa que o sistema aceita como "este app precisa continuar rodando", e ele
|
||||
* exige a notificacao permanente que aparece junto: e o preco combinado, o
|
||||
* usuario sempre ve que algo esta ativo.
|
||||
*
|
||||
* O wake lock parcial e separado e igualmente necessario: o servico impede o
|
||||
* congelamento do processo, mas nao impede a CPU de dormir.
|
||||
*/
|
||||
public class VoiceForegroundService extends Service {
|
||||
|
||||
public static final String ACTION_START = "com.resenhacord.app.VOICE_START";
|
||||
public static final String ACTION_STOP = "com.resenhacord.app.VOICE_STOP";
|
||||
public static final String ACTION_HANGUP = "com.resenhacord.app.VOICE_HANGUP";
|
||||
public static final String EXTRA_CHANNEL = "channel";
|
||||
|
||||
private static final String CHANNEL_ID = "voice_call";
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
|
||||
private PowerManager.WakeLock wakeLock;
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
String action = intent != null ? intent.getAction() : null;
|
||||
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stop();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (ACTION_HANGUP.equals(action)) {
|
||||
// O botao da notificacao nao desliga a chamada sozinho: quem sabe
|
||||
// como sair da sala e o app web. Avisamos o JavaScript e deixamos
|
||||
// ele encerrar; o servico para quando o JS confirmar a saida.
|
||||
VoicePlugin.emitHangup();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
String channel = intent != null ? intent.getStringExtra(EXTRA_CHANNEL) : null;
|
||||
startForegroundWithNotification(channel);
|
||||
acquireWakeLock();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void startForegroundWithNotification(String channelName) {
|
||||
createNotificationChannel();
|
||||
|
||||
Intent open = new Intent(this, MainActivity.class);
|
||||
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent openPending = PendingIntent.getActivity(
|
||||
this, 0, open, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Intent hangup = new Intent(this, VoiceForegroundService.class);
|
||||
hangup.setAction(ACTION_HANGUP);
|
||||
PendingIntent hangupPending = PendingIntent.getService(
|
||||
this, 1, hangup, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
String text = channelName != null && !channelName.isEmpty()
|
||||
? channelName
|
||||
: getString(R.string.voice_notification_text);
|
||||
|
||||
Notification notification = new Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.voice_notification_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_stat_voice)
|
||||
.setContentIntent(openPending)
|
||||
.addAction(new Notification.Action.Builder(
|
||||
null, getString(R.string.voice_notification_hangup), hangupPending).build())
|
||||
.setOngoing(true)
|
||||
.setUsesChronometer(true)
|
||||
.build();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// O tipo e obrigatorio a partir do Android 14: sem ele o sistema
|
||||
// recusa o servico e derruba o app com uma excecao.
|
||||
startForeground(NOTIFICATION_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||
|
||||
// Importancia baixa de proposito: a notificacao existe porque o sistema
|
||||
// exige, nao para chamar atencao. Som ou vibracao aqui tocariam a cada
|
||||
// entrada em call, por cima da propria chamada.
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.voice_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setDescription(getString(R.string.voice_channel_description));
|
||||
channel.setShowBadge(false);
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private void acquireWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) return;
|
||||
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Resenhacord::voice");
|
||||
// Sem timeout infinito: se o app morrer de um jeito que nao passe pelo
|
||||
// stop(), o sistema solta o lock sozinho depois de duas horas em vez de
|
||||
// segurar a CPU para sempre.
|
||||
wakeLock.acquire(2 * 60 * 60 * 1000L);
|
||||
}
|
||||
|
||||
private void releaseWakeLock() {
|
||||
if (wakeLock != null && wakeLock.isHeld()) {
|
||||
wakeLock.release();
|
||||
}
|
||||
wakeLock = null;
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
releaseWakeLock();
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseWakeLock();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.resenhacord.app;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* Ponte entre o app web e o servico de chamada.
|
||||
*
|
||||
* O app web e quem sabe quando ha uma call: ele fala com o LiveKit. O lado
|
||||
* nativo e quem sabe pedir ao Android para nao congelar o processo. Este plugin
|
||||
* so liga as duas pontas.
|
||||
*/
|
||||
@CapacitorPlugin(name = "VoiceForeground")
|
||||
public class VoicePlugin extends Plugin {
|
||||
|
||||
/**
|
||||
* Referencia fraca de proposito: o servico precisa avisar o JavaScript
|
||||
* quando alguem toca em "Sair" na notificacao, mas ele nao pode ser o
|
||||
* motivo de a instancia do plugin (e a Activity presa a ela) continuar
|
||||
* viva depois de destruida.
|
||||
*/
|
||||
private static WeakReference<VoicePlugin> instance = new WeakReference<>(null);
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
instance = new WeakReference<>(this);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void start(PluginCall call) {
|
||||
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||
intent.setAction(VoiceForegroundService.ACTION_START);
|
||||
intent.putExtra(VoiceForegroundService.EXTRA_CHANNEL, call.getString("channel"));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getContext().startForegroundService(intent);
|
||||
} else {
|
||||
getContext().startService(intent);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void stop(PluginCall call) {
|
||||
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
|
||||
intent.setAction(VoiceForegroundService.ACTION_STOP);
|
||||
getContext().startService(intent);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/** Chamado pelo servico quando o botao "Sair" da notificacao e tocado. */
|
||||
static void emitHangup() {
|
||||
VoicePlugin plugin = instance.get();
|
||||
if (plugin != null) {
|
||||
plugin.notifyListeners("hangup", new JSObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Icone pequeno da notificacao. O Android descarta a cor e usa so o formato
|
||||
(a partir do Lollipop ele e pintado de branco sobre a silhueta), entao aqui
|
||||
vale desenho solido e simples — um microfone.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Tela de abertura como camadas em vez das splash.png que o Capacitor gera para
|
||||
cada densidade e orientacao. Duas razoes: a cor de fundo fica exata em
|
||||
qualquer aparelho (as PNGs padrao sao claras e piscavam branco antes da
|
||||
WebView pintar), e a marca escala sozinha sem manter doze arquivos.
|
||||
-->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/brand_background" />
|
||||
<item
|
||||
android:drawable="@drawable/brand_mark"
|
||||
android:gravity="center"
|
||||
android:width="128dp"
|
||||
android:height="128dp" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- theme_color / background_color do manifest do PWA, para a abertura
|
||||
nativa e a WebView comecarem na mesma cor. -->
|
||||
<color name="brand_background">#0B0B10</color>
|
||||
<color name="colorPrimary">#0B0B10</color>
|
||||
<color name="colorPrimaryDark">#0B0B10</color>
|
||||
<color name="colorAccent">#A78BFA</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- A mesma base escura da marca; o icone adaptativo recorta o primeiro
|
||||
plano sobre ela em qualquer formato que o launcher use. -->
|
||||
<color name="ic_launcher_background">#0B0B10</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Resenhacord</string>
|
||||
<string name="title_activity_main">Resenhacord</string>
|
||||
<string name="package_name">com.resenhacord.app</string>
|
||||
<string name="custom_url_scheme">com.resenhacord.app</string>
|
||||
<string name="voice_channel_name">Chamada em andamento</string>
|
||||
<string name="voice_channel_description">Mantem a chamada de voz ativa com o app em segundo plano</string>
|
||||
<string name="voice_notification_title">Resenhacord</string>
|
||||
<string name="voice_notification_text">Em call</string>
|
||||
<string name="voice_notification_hangup">Sair</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
/**
|
||||
* O APK e uma casca fina: a WebView carrega a instancia pela rede em vez de
|
||||
* servir uma copia do app web embutida no pacote.
|
||||
*
|
||||
* Duas razoes, nesta ordem:
|
||||
*
|
||||
* 1. Atualizacao. O site muda a cada deploy. Com os arquivos embutidos, cada
|
||||
* mudanca no app web exigiria republicar e reinstalar o APK a mao — nao ha
|
||||
* updater automatico no Android como o electron-updater faz no desktop.
|
||||
* Pela rede, o telefone recebe a mesma versao que o navegador, na hora.
|
||||
*
|
||||
* 2. Origem. O cliente fala com /api e /ws na mesma origem. Embutido, a pagina
|
||||
* rodaria em https://localhost e toda chamada viraria cross-origin, o que
|
||||
* obrigaria a abrir CORS no servidor para uma origem que qualquer app pode
|
||||
* reivindicar.
|
||||
*
|
||||
* O preco e nao funcionar offline, o que para um app de chat em tempo real nao
|
||||
* e perda: sem servidor nao ha o que mostrar. A pagina em public/ so aparece se
|
||||
* a WebView nao alcancar o servidor.
|
||||
*/
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.resenhacord.app',
|
||||
appName: 'Resenhacord',
|
||||
webDir: 'public',
|
||||
android: {
|
||||
// A captura de audio e video da WebView e concedida pelo bridge do
|
||||
// Capacitor, que traduz o pedido do getUserMedia para a permissao nativa.
|
||||
allowMixedContent: false,
|
||||
},
|
||||
server: {
|
||||
url: 'https://chat.resenha.website',
|
||||
// Sem cleartext: a instancia e servida por HTTPS (Caddy), e o WebRTC do
|
||||
// LiveKit exige contexto seguro de qualquer forma.
|
||||
cleartext: false,
|
||||
androidScheme: 'https',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@backspace/mobile",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Resenhacord para Android",
|
||||
"scripts": {
|
||||
"sync": "cap sync android",
|
||||
"open": "cap open android",
|
||||
"build:debug": "cd android && ./gradlew assembleDebug",
|
||||
"build:release": "cd android && ./gradlew assembleRelease"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^7.0.0",
|
||||
"@capacitor/core": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Resenhacord</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100dvh;
|
||||
display: grid; place-items: center; padding: 24px;
|
||||
background: #0b0b10; color: #e7e7ee;
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
h1 { font-size: 19px; margin: 0 0 8px; }
|
||||
p { margin: 0; color: #9a9aab; max-width: 30ch; }
|
||||
</style>
|
||||
<h1>Sem conexão</h1>
|
||||
<p>Não deu pra falar com o servidor. Confere a internet e abre de novo.</p>
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { InAppNotifications } from '../ui/InAppNotifications';
|
||||
import { UpdateBanner } from '../ui/UpdateBanner';
|
||||
import { AndroidVoiceService } from '../voice/AndroidVoiceService';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { SpaceSidebar } from './SpaceSidebar';
|
||||
@@ -430,6 +431,7 @@ export function AppLayout() {
|
||||
(e.g. immediately after Join, or after popping voice-full back to
|
||||
the root). */}
|
||||
<SoundController />
|
||||
<AndroidVoiceService />
|
||||
<UpdateBanner />
|
||||
<InAppNotifications />
|
||||
<GlobalAudioRenderer />
|
||||
@@ -474,8 +476,10 @@ export function AppLayout() {
|
||||
<ImagePreview />
|
||||
<PictureInPicture />
|
||||
<SoundController />
|
||||
<AndroidVoiceService />
|
||||
<GlobalAudioRenderer />
|
||||
<NotificationController />
|
||||
<UpdateBanner />
|
||||
<UpdateToast />
|
||||
|
||||
{/* User Profile Popout */}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
|
||||
type Status = { state: string; version: string | null };
|
||||
|
||||
function installBridge(status: Status) {
|
||||
const listeners: Array<(info: { version: string }) => void> = [];
|
||||
(window as unknown as { backspace: unknown }).backspace = {
|
||||
onUpdateDownloaded: (cb: (info: { version: string }) => void) => listeners.push(cb),
|
||||
getUpdateStatus: () => Promise.resolve(status),
|
||||
installUpdate: vi.fn(),
|
||||
};
|
||||
return listeners;
|
||||
}
|
||||
|
||||
describe('UpdateBanner', () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as { backspace?: unknown }).backspace;
|
||||
});
|
||||
|
||||
it('shows a staged update the live event never announced', async () => {
|
||||
// The regression: `update-downloaded` is pushed once, to whichever page is
|
||||
// loaded at that moment. Downloading with the window in the tray, before
|
||||
// this component mounts, or across a reload used to lose it for good — the
|
||||
// main process latches off further polls once an update is staged, so the
|
||||
// banner never came back and only a manual check surfaced it.
|
||||
installBridge({ state: 'downloaded', version: '2.2.0' });
|
||||
|
||||
render(<UpdateBanner />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/2\.2\.0/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('stays hidden when nothing is staged', async () => {
|
||||
installBridge({ state: 'idle', version: null });
|
||||
|
||||
const { container } = render(<UpdateBanner />);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('still reacts to the live event', async () => {
|
||||
const listeners = installBridge({ state: 'idle', version: null });
|
||||
|
||||
render(<UpdateBanner />);
|
||||
await waitFor(() => expect(listeners.length).toBe(1));
|
||||
listeners[0]!({ version: '2.3.0' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/2\.3\.0/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,19 @@ export function UpdateBanner() {
|
||||
// dispensar significa "agora não", não "nunca mais".
|
||||
setDismissed(false);
|
||||
});
|
||||
|
||||
// O evento acima é disparado uma única vez, para a página que estiver
|
||||
// carregada naquele instante — se o download terminar com a janela na
|
||||
// bandeja, antes deste componente montar, ou se a página recarregar, ele se
|
||||
// perde e o aviso nunca aparece. Por isso perguntamos o estado atual ao
|
||||
// montar, em vez de depender só de pegar o evento ao vivo.
|
||||
let alive = true;
|
||||
window.backspace.getUpdateStatus?.()
|
||||
.then((status) => {
|
||||
if (alive && status.state === 'downloaded' && status.version) setVersion(status.version);
|
||||
})
|
||||
.catch(() => { /* versão antiga do app sem esse IPC — segue só com o evento */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (!version || dismissed) return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface UpdateError {
|
||||
message: string;
|
||||
@@ -7,84 +8,45 @@ interface UpdateError {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent toast shown when an Electron auto-update has been downloaded
|
||||
* or when auto-update fails (offers manual download link).
|
||||
* Renders nothing in browser environments.
|
||||
* Aviso de falha na atualização automática, com link para baixar à mão.
|
||||
*
|
||||
* O caso de sucesso ("já baixou, é só reiniciar") ficava aqui também, e
|
||||
* aparecia junto com o UpdateBanner — dois avisos da mesma coisa, um deles em
|
||||
* inglês. Esse pedaço saiu; sucesso agora é só do UpdateBanner.
|
||||
*/
|
||||
export function UpdateToast() {
|
||||
const [downloadedVersion, setDownloadedVersion] = useState<string | null>(null);
|
||||
const t = useT();
|
||||
const [failedUpdate, setFailedUpdate] = useState<UpdateError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron() || !window.backspace) return;
|
||||
|
||||
window.backspace.onUpdateDownloaded((info) => {
|
||||
setDownloadedVersion(info.version);
|
||||
// Auto-download succeeded — clear any previous error state
|
||||
setFailedUpdate(null);
|
||||
});
|
||||
|
||||
window.backspace.onUpdateError((error) => {
|
||||
setFailedUpdate(error);
|
||||
});
|
||||
window.backspace.onUpdateError((error) => setFailedUpdate(error));
|
||||
// Um download que dá certo apaga o erro anterior: o aviso de falha não pode
|
||||
// sobreviver a uma tentativa bem-sucedida.
|
||||
window.backspace.onUpdateDownloaded(() => setFailedUpdate(null));
|
||||
}, []);
|
||||
|
||||
// Nothing to show
|
||||
if (!downloadedVersion && !failedUpdate) return null;
|
||||
if (!failedUpdate) return null;
|
||||
|
||||
// Auto-download succeeded — show restart toast
|
||||
if (downloadedVersion) {
|
||||
return (
|
||||
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
|
||||
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[340px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-txt-primary">Update ready</p>
|
||||
<p className="text-xs text-txt-secondary truncate">
|
||||
Version {downloadedVersion} has been downloaded
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.backspace?.installUpdate()}
|
||||
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDownloadedVersion(null)}
|
||||
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-download failed — show manual download toast
|
||||
return (
|
||||
<div className="fixed bottom-6 left-6 z-[300] animate-slide-up">
|
||||
<div className="glass-pill rounded-xl px-4 py-3 flex items-center gap-3 max-w-[380px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-txt-primary">Update failed</p>
|
||||
<p className="text-xs text-txt-secondary truncate">
|
||||
Auto-update failed — download manually
|
||||
</p>
|
||||
<p className="text-sm font-medium text-txt-primary">{t('update.failed')}</p>
|
||||
<p className="text-xs text-txt-secondary truncate">{t('update.failedBody')}</p>
|
||||
</div>
|
||||
<a
|
||||
href={failedUpdate!.releaseUrl}
|
||||
href={failedUpdate.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-primary hover:bg-accent-primary/80 text-white transition-colors"
|
||||
>
|
||||
Download
|
||||
{t('update.download')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setFailedUpdate(null)}
|
||||
className="shrink-0 p-1 text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('update.dismiss')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, act, waitFor } from '@testing-library/react';
|
||||
|
||||
// A voiceStore arrasta o AudioManager, que carrega um AudioWorklet inexistente
|
||||
// no jsdom. Mesmo stub que os outros testes que tocam nesta store usam.
|
||||
vi.mock('../../audio/AudioManager', () => ({
|
||||
AudioManager: { getInstance: () => ({ resumeContext: vi.fn() }) },
|
||||
}));
|
||||
|
||||
import { AndroidVoiceService } from './AndroidVoiceService';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
|
||||
const start = vi.fn(() => Promise.resolve());
|
||||
const stop = vi.fn(() => Promise.resolve());
|
||||
let hangupHandler: (() => void) | null = null;
|
||||
|
||||
function installCapacitor(platform = 'android') {
|
||||
(globalThis as { Capacitor?: unknown }).Capacitor = {
|
||||
isNativePlatform: () => true,
|
||||
getPlatform: () => platform,
|
||||
Plugins: {
|
||||
VoiceForeground: {
|
||||
start,
|
||||
stop,
|
||||
addListener: (_e: string, h: () => void) => {
|
||||
hangupHandler = h;
|
||||
return Promise.resolve({ remove: () => { hangupHandler = null; } });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AndroidVoiceService', () => {
|
||||
beforeEach(() => {
|
||||
start.mockClear();
|
||||
stop.mockClear();
|
||||
hangupHandler = null;
|
||||
useVoiceStore.setState({ isLiveKitConnected: false, currentVoiceChannelId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { Capacitor?: unknown }).Capacitor;
|
||||
});
|
||||
|
||||
it('starts the service when a call connects and stops it when it ends', async () => {
|
||||
installCapacitor();
|
||||
render(<AndroidVoiceService />);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
|
||||
});
|
||||
await waitFor(() => expect(start).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
useVoiceStore.setState({ isLiveKitConnected: false, currentVoiceChannelId: null });
|
||||
});
|
||||
await waitFor(() => expect(stop).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('starts on mount when a call is already in progress', async () => {
|
||||
// A WebView pode recarregar no meio da chamada. Se o servico so subisse na
|
||||
// proxima transicao, a call seguiria desprotegida ate alguem sair e entrar
|
||||
// de novo — e cairia ao trancar a tela.
|
||||
installCapacitor();
|
||||
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
|
||||
|
||||
render(<AndroidVoiceService />);
|
||||
|
||||
await waitFor(() => expect(start).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('leaves the call when the notification hangup is tapped', async () => {
|
||||
installCapacitor();
|
||||
const disconnectFn = vi.fn(() => Promise.resolve());
|
||||
useVoiceStore.setState({ disconnectFn });
|
||||
|
||||
render(<AndroidVoiceService />);
|
||||
await waitFor(() => expect(hangupHandler).not.toBeNull());
|
||||
|
||||
act(() => hangupHandler!());
|
||||
|
||||
expect(disconnectFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing outside the Android app', async () => {
|
||||
// Mesmo componente montado no navegador e no desktop: nao pode tocar em
|
||||
// nada nativo nem quebrar.
|
||||
render(<AndroidVoiceService />);
|
||||
|
||||
act(() => {
|
||||
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
|
||||
});
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a web build of Capacitor', async () => {
|
||||
installCapacitor('web');
|
||||
render(<AndroidVoiceService />);
|
||||
|
||||
act(() => {
|
||||
useVoiceStore.setState({ isLiveKitConnected: true, currentVoiceChannelId: 'c-1' });
|
||||
});
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import {
|
||||
isAndroidApp,
|
||||
onAndroidHangup,
|
||||
startAndroidVoiceService,
|
||||
stopAndroidVoiceService,
|
||||
} from '../../platform/androidVoice';
|
||||
|
||||
/**
|
||||
* Liga e desliga o servico de primeiro plano do Android conforme a chamada
|
||||
* comeca e termina. Nao renderiza nada; fora do APK nao faz nada.
|
||||
*
|
||||
* Assina a store direto, sem seletor de re-render: o efeito colateral e uma
|
||||
* chamada ao nativo, e uma re-renderizacao por mudanca de estado da voz seria
|
||||
* so custo.
|
||||
*/
|
||||
export function AndroidVoiceService() {
|
||||
const wasConnected = useRef(useVoiceStore.getState().isLiveKitConnected);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAndroidApp()) return;
|
||||
|
||||
const channelLabel = (channelId: string | null): string | undefined => {
|
||||
if (!channelId) return undefined;
|
||||
if (channelId.startsWith('dm-')) return 'Chamada';
|
||||
return useSpaceStore.getState().channels.find((c) => c.id === channelId)?.name;
|
||||
};
|
||||
|
||||
// Entrar na call e o gatilho, mas a montagem pode acontecer com uma
|
||||
// chamada ja em andamento — uma recarga da WebView, por exemplo. Nesse caso
|
||||
// o servico precisa subir agora, nao na proxima transicao.
|
||||
const initial = useVoiceStore.getState();
|
||||
if (initial.isLiveKitConnected) {
|
||||
startAndroidVoiceService(channelLabel(initial.currentVoiceChannelId));
|
||||
}
|
||||
|
||||
const unsubscribe = useVoiceStore.subscribe((state) => {
|
||||
const connected = state.isLiveKitConnected;
|
||||
if (connected === wasConnected.current) return;
|
||||
wasConnected.current = connected;
|
||||
|
||||
if (connected) startAndroidVoiceService(channelLabel(state.currentVoiceChannelId));
|
||||
else stopAndroidVoiceService();
|
||||
});
|
||||
|
||||
const removeHangup = onAndroidHangup(() => {
|
||||
// A notificacao so pede para sair; quem sabe encerrar a sessao do LiveKit
|
||||
// e o app web. O servico para quando a store refletir a saida, pelo
|
||||
// mesmo caminho de quando se sai pela interface.
|
||||
const { disconnectFn, leaveVoice } = useVoiceStore.getState();
|
||||
if (disconnectFn) void disconnectFn();
|
||||
else leaveVoice();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
removeHangup();
|
||||
// Desmontar nao significa sair da call (a arvore de componentes pode
|
||||
// remontar), mas deixar o servico ligado sem ninguem para desliga-lo
|
||||
// seria pior: uma notificacao permanente que so some matando o app.
|
||||
if (!useVoiceStore.getState().isLiveKitConnected) stopAndroidVoiceService();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -65,6 +65,10 @@ export const en = {
|
||||
'update.readyVersion': 'Version {version} is ready to install.',
|
||||
'update.restart': 'Restart to update',
|
||||
'update.later': 'Later',
|
||||
'update.failed': 'Update failed',
|
||||
'update.failedBody': 'Auto-update failed — download it manually.',
|
||||
'update.download': 'Download',
|
||||
'update.dismiss': 'Dismiss',
|
||||
|
||||
// Custom emojis and stickers
|
||||
'expressions.title': 'Emojis & Stickers',
|
||||
|
||||
@@ -64,6 +64,10 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'update.readyVersion': 'A versão {version} está pronta para instalar.',
|
||||
'update.restart': 'Reiniciar para atualizar',
|
||||
'update.later': 'Depois',
|
||||
'update.failed': 'Falha ao atualizar',
|
||||
'update.failedBody': 'A atualização automática falhou — baixe manualmente.',
|
||||
'update.download': 'Baixar',
|
||||
'update.dismiss': 'Dispensar',
|
||||
|
||||
// Emojis e figurinhas
|
||||
'expressions.title': 'Emojis e figurinhas',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Ponte com o servico de chamada do app Android.
|
||||
*
|
||||
* Trancar a tela derruba a call: o Android congela o processo, e uma WebView
|
||||
* congelada para de rodar o JavaScript que mantem a conexao do LiveKit. O lado
|
||||
* nativo resolve isso com um servico em primeiro plano, mas so o app web sabe
|
||||
* quando existe uma chamada — daqui sai esse aviso.
|
||||
*
|
||||
* Fora do app Android nada disto existe, e todas as funcoes viram no-op: o
|
||||
* mesmo codigo roda no navegador e no desktop sem condicional no chamador.
|
||||
*/
|
||||
|
||||
interface VoiceForegroundPlugin {
|
||||
start: (options: { channel?: string }) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
addListener: (event: 'hangup', handler: () => void) => Promise<{ remove: () => void }>;
|
||||
}
|
||||
|
||||
interface CapacitorGlobal {
|
||||
isNativePlatform?: () => boolean;
|
||||
getPlatform?: () => string;
|
||||
Plugins?: { VoiceForeground?: VoiceForegroundPlugin };
|
||||
}
|
||||
|
||||
function capacitor(): CapacitorGlobal | undefined {
|
||||
return (globalThis as { Capacitor?: CapacitorGlobal }).Capacitor;
|
||||
}
|
||||
|
||||
/** True somente dentro do APK — nao no navegador do celular nem no desktop. */
|
||||
export function isAndroidApp(): boolean {
|
||||
const cap = capacitor();
|
||||
return Boolean(cap?.isNativePlatform?.() && cap.getPlatform?.() === 'android');
|
||||
}
|
||||
|
||||
function plugin(): VoiceForegroundPlugin | undefined {
|
||||
return isAndroidApp() ? capacitor()?.Plugins?.VoiceForeground : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liga o servico. Idempotente do lado nativo: chamar duas vezes so atualiza a
|
||||
* notificacao, nao empilha servicos.
|
||||
*/
|
||||
export function startAndroidVoiceService(channelName?: string): void {
|
||||
// Um erro aqui nao pode derrubar a entrada na call. O pior caso de falhar em
|
||||
// silencio e a chamada cair com a tela apagada, que e exatamente o
|
||||
// comportamento de quem nao tem o servico — nunca pior que isso.
|
||||
plugin()?.start({ channel: channelName }).catch(() => { /* segue sem o servico */ });
|
||||
}
|
||||
|
||||
export function stopAndroidVoiceService(): void {
|
||||
plugin()?.stop().catch(() => { /* o servico morre junto com o processo */ });
|
||||
}
|
||||
|
||||
/**
|
||||
* Escuta o botao "Sair" da notificacao. Devolve uma funcao para cancelar a
|
||||
* inscricao, ou uma vazia fora do Android.
|
||||
*/
|
||||
export function onAndroidHangup(handler: () => void): () => void {
|
||||
const p = plugin();
|
||||
if (!p) return () => { /* nada a cancelar */ };
|
||||
|
||||
let remove: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
p.addListener('hangup', handler)
|
||||
.then((h) => {
|
||||
// A inscricao e assincrona: se o componente desmontar antes dela
|
||||
// resolver, cancela assim que chegar em vez de vazar o listener.
|
||||
if (cancelled) h.remove();
|
||||
else remove = h.remove;
|
||||
})
|
||||
.catch(() => { /* sem o plugin, sem botao */ });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
remove?.();
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,7 @@ interface BackspaceElectronAPI {
|
||||
onUpdateError: (callback: (error: { message: string; releaseUrl: string }) => void) => void;
|
||||
installUpdate: () => void;
|
||||
checkForUpdates: () => void;
|
||||
getUpdateStatus: () => Promise<{ state: 'idle' | 'checking' | 'downloading' | 'downloaded' | 'error'; version: string | null }>;
|
||||
getVersion: () => Promise<string>;
|
||||
|
||||
// Window focus (Task 2.2)
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
# O app Android fica de fora do workspace de proposito. Ele usa npm com um
|
||||
# node_modules normal: o layout de links simbolicos do pnpm quebra as
|
||||
# suposicoes de caminho do CLI do Capacitor e do Gradle. Mantendo-o fora, o
|
||||
# pnpm-lock.yaml e o CI do desktop/web seguem intocados por ele.
|
||||
- "!packages/mobile"
|
||||
|
||||