feat: initial commit for AlarmeZap

This commit is contained in:
Murilo Scolari 2026-11-01 20:38:37 -03:00
commit 9579607826
37 changed files with 4737 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# WhatsApp Auth & Sensitive Data
server/auth_info/
server/contacts.json
server/.env
*.env
# Node.js Dependencies
server/node_modules/
node_modules/
# Android / Gradle
android/.gradle/
android/build/
android/app/build/
android/.idea/
android/local.properties
android/.kotlin/
*.iml
.gradle/
build/
.idea/
# System files
.DS_Store
Thumbs.db

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# AlarmeZap 🚨
Solução completa de alarme sonoro contínuo ("pesado") disparado via WhatsApp.
---
## 📁 Estrutura do Projeto
- `server/`: Servidor Node.js com `@whiskeysockets/baileys` e WebSocket (`ws`).
- `android/`: Aplicativo Android nativo em **Kotlin + Jetpack Compose**.
- `dist/alarmezap.apk`: **APK compilado e pronto para instalação**.
---
## 1. 🖥️ Servidor (Linux / Node.js)
### Pré-requisitos
- Node.js >= 20
- npm
### Como rodar:
```bash
cd server
npm install
npm start
```
1. Na primeira execução, um **QR Code** será exibido diretamente no terminal.
2. Abra o WhatsApp no celular -> **Aparelhos conectados** -> **Conectar aparelho** e escaneie o código.
3. As credenciais da sessão são salvas na pasta `auth_info/` para reconexão automática contínua.
4. O servidor WebSocket ficará ouvindo na porta `8080` (configurável via `.env`).
---
## 2. 📱 Aplicativo Android
### APK Pronto para Instalação:
O binário compilado encontra-se em:
**[`dist/alarmezap.apk`](file:///home/murilo/Documentos/alarmeZap/dist/alarmezap.apk)**
Para instalar diretamente via USB com ADB:
```bash
adb install -r dist/alarmezap.apk
```
### Como Recompilar pelo Terminal:
```bash
cd android
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # ou o JDK instalado
./gradlew assembleDebug
```
O APK gerado ficará em `android/app/build/outputs/apk/debug/app-debug.apk`.
### Abrindo no Android Studio:
Basta abrir a pasta `android/` no Android Studio.
---
## 3. ⚙️ Como Utilizar o Sistema
1. Abra o **AlarmeZap** no celular.
2. **Conceda as permissões essenciais** exibidas no topo da tela:
- *Ignorar Otimização de Bateria* (necessário para o Android não suspender o WebSocket em segundo plano).
- *Sobrepor outros apps / Usar tela cheia* (para o alarme acender a tela mesmo bloqueada).
- *Acesso a Não Perturbe* (para forçar o áudio no volume máximo mesmo se o aparelho estiver mudo).
3. **Configure o Servidor**: Insira o IP da sua máquina Linux na rede local (ex: `192.168.1.100`) e porta `8080`. Toque em **Salvar Servidor**.
4. **Cadastre os Números**: Digite os números autorizados com DDI e DDD (ex: `5511999999999`) e clique em **Adicionar**. A lista é automaticamente sincronizada com o servidor Linux.
5. **Arme o Sistema**: Ative o Switch principal **SISTEMA ARMADO**.
6. Uma notificação fixa de primeiro plano (*Foreground Service*) manterá a conexão ativa 24/7.
7. Quando qualquer um dos contatos cadastrados enviar qualquer mensagem no WhatsApp:
- O servidor Linux identifica o remetente.
- Envia o evento `trigger_alarm` para o celular.
- O celular aguarda o delay exato de 1 segundo.
- O alarme sobe no volume máximo contínuo (`STREAM_ALARM`), acende a tela de bloqueio e exibe o botão gigante **DESLIGAR ALARME**.

View file

@ -0,0 +1,54 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.alarmezap"
compileSdk = 37
defaultConfig {
applicationId = "com.alarmezap"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Assinado com a chave de debug para facilitar a instalação direta (sideload).
// Troque por sua própria signingConfig se for distribuir o app.
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.09.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.core:core-ktx:1.19.1")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

3
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,3 @@
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**

View file

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Rede / WebSocket -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Serviço em primeiro plano -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- CPU / tela -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.TURN_SCREEN_ON" />
<!-- Notificações / tela cheia / sobreposição -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Áudio / vibração / Não Perturbe -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Contatos do telefone -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".AlarmeZapApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AlarmeZap"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.AlarmActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:showWhenLocked="true"
android:taskAffinity="com.alarmezap.alarm"
android:theme="@style/Theme.AlarmeZap.Alarm"
android:turnScreenOn="true" />
<service
android:name=".service.AlarmMonitorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Mantém conexão WebSocket persistente com servidor próprio para disparar alarme sonoro crítico." />
</service>
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".alarm.StopAlarmReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,13 @@
package com.alarmezap
import android.app.Application
import com.alarmezap.data.SettingsRepository
import com.alarmezap.util.Notifications
class AlarmeZapApp : Application() {
override fun onCreate() {
super.onCreate()
SettingsRepository.init(this)
Notifications.createChannels(this)
}
}

View file

@ -0,0 +1,301 @@
package com.alarmezap.alarm
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.media.ToneGenerator
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import com.alarmezap.util.Notifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class AlarmState(
val ringing: Boolean = false,
val sender: String? = null,
val senderName: String? = null,
val startedAt: Long = 0L,
)
/**
* Gerenciador de áudio/alarme: som contínuo no canal de ALARME, volume máximo forçado,
* bypass do Não Perturbe, vibração, wake lock de tela e exibição da tela cheia.
*/
object AlarmController {
private const val TAG = "AlarmController"
private const val VOLUME_ENFORCE_INTERVAL_MS = 1500L
private const val SCREEN_WAKE_TIMEOUT_MS = 30 * 60 * 1000L
private val _state = MutableStateFlow(AlarmState())
val state: StateFlow<AlarmState> = _state.asStateFlow()
@Volatile
var lastDismissedAt: Long = 0L
private set
val isRinging: Boolean
get() = _state.value.ringing
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var mediaPlayer: MediaPlayer? = null
private var toneGenerator: ToneGenerator? = null
private var vibrator: Vibrator? = null
private var screenWakeLock: PowerManager.WakeLock? = null
private var savedAlarmVolume: Int? = null
private var savedInterruptionFilter: Int? = null
private val volumeEnforcer = object : Runnable {
override fun run() {
val ctx = appContext ?: return
if (!isRinging) return
setAlarmVolumeToMax(ctx)
main.postDelayed(this, VOLUME_ENFORCE_INTERVAL_MS)
}
}
@Synchronized
fun start(context: Context, sender: String, senderName: String? = null) {
val ctx = context.applicationContext
appContext = ctx
if (_state.value.ringing) {
_state.value = _state.value.copy(sender = sender, senderName = senderName)
Notifications.notify(ctx, Notifications.ID_ALARM, Notifications.buildAlarmNotification(ctx, sender, senderName))
return
}
Log.i(TAG, "Disparando alarme. Remetente: $sender (Nome: $senderName)")
_state.value = AlarmState(
ringing = true,
sender = sender,
senderName = senderName,
startedAt = System.currentTimeMillis()
)
wakeScreen(ctx)
overrideDoNotDisturb(ctx)
saveAndMaximizeAlarmVolume(ctx)
startSound(ctx)
startVibration(ctx)
showAlarmUi(ctx, sender, senderName)
main.removeCallbacks(volumeEnforcer)
main.postDelayed(volumeEnforcer, VOLUME_ENFORCE_INTERVAL_MS)
}
@Synchronized
fun stop(context: Context) {
val ctx = context.applicationContext
Notifications.cancel(ctx, Notifications.ID_ALARM)
if (!_state.value.ringing) return
Log.i(TAG, "Alarme desligado pelo usuário")
lastDismissedAt = System.currentTimeMillis()
main.removeCallbacks(volumeEnforcer)
stopSound()
stopVibration()
restoreAlarmVolume(ctx)
restoreDoNotDisturb(ctx)
releaseScreen()
_state.value = AlarmState()
}
// ------------------------------------------------------------------ Som
private fun alarmAttributes(): AudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
private fun candidateUris(ctx: Context): List<Uri> = buildList {
runCatching { RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_ALARM) }
.getOrNull()?.let(::add)
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)?.let(::add)
runCatching { RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_RINGTONE) }
.getOrNull()?.let(::add)
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)?.let(::add)
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)?.let(::add)
}.distinct()
private fun startSound(ctx: Context) {
for (uri in candidateUris(ctx)) {
val player = MediaPlayer()
try {
player.setAudioAttributes(alarmAttributes())
player.setDataSource(ctx, uri)
player.isLooping = true
player.setVolume(1f, 1f)
player.setOnErrorListener { mp, what, extra ->
Log.e(TAG, "Erro no MediaPlayer what=$what extra=$extra")
main.post { onPlayerError(mp) }
true
}
player.prepare()
player.start()
mediaPlayer = player
Log.i(TAG, "Tocando alarme: $uri")
return
} catch (e: Exception) {
Log.w(TAG, "Falha ao tocar $uri: ${e.message}")
player.release()
}
}
startToneFallback()
}
@Synchronized
private fun onPlayerError(mp: MediaPlayer) {
if (mediaPlayer !== mp || !isRinging) return
runCatching { mp.release() }
mediaPlayer = null
startToneFallback()
}
private fun startToneFallback() {
try {
toneGenerator = ToneGenerator(AudioManager.STREAM_ALARM, ToneGenerator.MAX_VOLUME).also {
it.startTone(ToneGenerator.TONE_CDMA_EMERGENCY_RINGBACK)
}
Log.i(TAG, "Usando ToneGenerator como fallback")
} catch (e: RuntimeException) {
Log.e(TAG, "Nenhuma fonte de áudio disponível", e)
}
}
private fun stopSound() {
mediaPlayer?.let { player ->
runCatching { if (player.isPlaying) player.stop() }
runCatching { player.release() }
}
mediaPlayer = null
toneGenerator?.let { tone ->
runCatching { tone.stopTone() }
runCatching { tone.release() }
}
toneGenerator = null
}
// --------------------------------------------------------------- Volume
private fun saveAndMaximizeAlarmVolume(ctx: Context) {
val am = ctx.getSystemService(AudioManager::class.java)
if (savedAlarmVolume == null) {
savedAlarmVolume = runCatching { am.getStreamVolume(AudioManager.STREAM_ALARM) }.getOrNull()
}
setAlarmVolumeToMax(ctx)
}
private fun setAlarmVolumeToMax(ctx: Context) {
val am = ctx.getSystemService(AudioManager::class.java)
try {
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
if (am.getStreamVolume(AudioManager.STREAM_ALARM) != max) {
am.setStreamVolume(AudioManager.STREAM_ALARM, max, 0)
}
} catch (e: SecurityException) {
Log.w(TAG, "Sem permissão para alterar volume: ${e.message}")
}
}
private fun restoreAlarmVolume(ctx: Context) {
val volume = savedAlarmVolume ?: return
savedAlarmVolume = null
val am = ctx.getSystemService(AudioManager::class.java)
runCatching { am.setStreamVolume(AudioManager.STREAM_ALARM, volume, 0) }
}
// ---------------------------------------------------------- Não Perturbe
private fun overrideDoNotDisturb(ctx: Context) {
val nm = ctx.getSystemService(NotificationManager::class.java)
if (!nm.isNotificationPolicyAccessGranted) {
Log.w(TAG, "Sem acesso ao Não Perturbe; dependendo do canal de alarme")
return
}
val current = nm.currentInterruptionFilter
if (current != NotificationManager.INTERRUPTION_FILTER_ALL &&
current != NotificationManager.INTERRUPTION_FILTER_UNKNOWN
) {
savedInterruptionFilter = current
runCatching { nm.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL) }
.onFailure { Log.w(TAG, "Falha ao desativar Não Perturbe: ${it.message}") }
}
}
private fun restoreDoNotDisturb(ctx: Context) {
val filter = savedInterruptionFilter ?: return
savedInterruptionFilter = null
val nm = ctx.getSystemService(NotificationManager::class.java)
if (nm.isNotificationPolicyAccessGranted) {
runCatching { nm.setInterruptionFilter(filter) }
}
}
// -------------------------------------------------------------- Vibração
@Suppress("DEPRECATION")
private fun startVibration(ctx: Context) {
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
ctx.getSystemService(Vibrator::class.java)
}
if (vib == null || !vib.hasVibrator()) return
val effect = VibrationEffect.createWaveform(longArrayOf(0, 1000, 500), 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
vib.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ALARM))
} else {
vib.vibrate(effect, alarmAttributes())
}
vibrator = vib
}
private fun stopVibration() {
runCatching { vibrator?.cancel() }
vibrator = null
}
// ------------------------------------------------------------ Tela / UI
@Suppress("DEPRECATION")
@SuppressLint("WakelockTimeout")
private fun wakeScreen(ctx: Context) {
val pm = ctx.getSystemService(PowerManager::class.java)
releaseScreen()
screenWakeLock = pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
"AlarmeZap:AlarmScreen",
).apply {
setReferenceCounted(false)
acquire(SCREEN_WAKE_TIMEOUT_MS)
}
}
private fun releaseScreen() {
screenWakeLock?.let { if (it.isHeld) runCatching { it.release() } }
screenWakeLock = null
}
private fun showAlarmUi(ctx: Context, sender: String, senderName: String? = null) {
Notifications.notify(ctx, Notifications.ID_ALARM, Notifications.buildAlarmNotification(ctx, sender, senderName))
try {
ctx.startActivity(Notifications.alarmActivityIntent(ctx))
} catch (e: Exception) {
Log.w(TAG, "Início direto da Activity bloqueado; usando full-screen intent: ${e.message}")
}
}
}

View file

@ -0,0 +1,12 @@
package com.alarmezap.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Recebe a ação "DESLIGAR ALARME" da notificação. */
class StopAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AlarmController.stop(context)
}
}

View file

@ -0,0 +1,169 @@
package com.alarmezap.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
data class MonitoredContact(
val number: String,
val name: String = "",
val enabled: Boolean = true,
)
data class ServerConfig(
val host: String = "",
val port: Int = 8080,
val token: String = "",
)
enum class AddContactResult { ADDED, INVALID, DUPLICATE }
/**
* Configurações persistentes (SharedPreferences) expostas como StateFlow,
* compartilhadas entre a UI e o Foreground Service.
*/
object SettingsRepository {
private const val PREFS_NAME = "alarmezap_prefs"
private const val KEY_ARMED = "armed"
private const val KEY_HOST = "server_host"
private const val KEY_PORT = "server_port"
private const val KEY_TOKEN = "server_token"
private const val KEY_CONTACTS = "contacts"
private lateinit var prefs: SharedPreferences
private val _armed = MutableStateFlow(false)
val armed: StateFlow<Boolean> = _armed.asStateFlow()
private val _server = MutableStateFlow(ServerConfig())
val server: StateFlow<ServerConfig> = _server.asStateFlow()
private val _contacts = MutableStateFlow<List<MonitoredContact>>(emptyList())
val contacts: StateFlow<List<MonitoredContact>> = _contacts.asStateFlow()
@Synchronized
fun init(context: Context) {
if (::prefs.isInitialized) return
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
_armed.value = prefs.getBoolean(KEY_ARMED, false)
_server.value = ServerConfig(
host = prefs.getString(KEY_HOST, "") ?: "",
port = prefs.getInt(KEY_PORT, 8080),
token = prefs.getString(KEY_TOKEN, "") ?: "",
)
_contacts.value = parseContacts(prefs.getString(KEY_CONTACTS, "[]") ?: "[]")
}
fun setArmed(value: Boolean) {
prefs.edit(commit = true) { putBoolean(KEY_ARMED, value) }
_armed.value = value
}
fun setServer(config: ServerConfig) {
prefs.edit {
putString(KEY_HOST, config.host)
putInt(KEY_PORT, config.port)
putString(KEY_TOKEN, config.token)
}
_server.value = config
}
fun normalize(raw: String): String = raw.filter(Char::isDigit)
@Synchronized
fun addContact(rawNumber: String, name: String = "", enabled: Boolean = true): AddContactResult {
val number = normalize(rawNumber)
if (number.length !in 10..15) return AddContactResult.INVALID
val current = _contacts.value
if (current.any { it.number == number }) return AddContactResult.DUPLICATE
val updated = current + MonitoredContact(number = number, name = name.trim(), enabled = enabled)
saveContacts(updated)
return AddContactResult.ADDED
}
@Synchronized
fun removeContact(number: String) {
saveContacts(_contacts.value.filterNot { it.number == number })
}
@Synchronized
fun toggleContact(number: String, enabled: Boolean) {
val updated = _contacts.value.map {
if (it.number == number) it.copy(enabled = enabled) else it
}
saveContacts(updated)
}
@Synchronized
fun updateContactName(number: String, newName: String) {
val updated = _contacts.value.map {
if (it.number == number) it.copy(name = newName.trim()) else it
}
saveContacts(updated)
}
/** Retorna apenas a lista de números que estão habilitados para monitoramento. */
fun getEnabledNumbers(): List<String> {
return _contacts.value.filter { it.enabled }.map { it.number }
}
/** Busca o nome atribuído a um número (ou retorna nulo se não encontrado). */
fun getContactName(rawNumber: String?): String? {
if (rawNumber == null) return null
val num = normalize(rawNumber)
val contact = _contacts.value.firstOrNull { it.number == num || it.number.endsWith(num) || num.endsWith(it.number) }
return contact?.name?.takeIf { it.isNotBlank() }
}
private fun saveContacts(list: List<MonitoredContact>) {
val array = JSONArray()
list.forEach { contact ->
val obj = JSONObject().apply {
put("number", contact.number)
put("name", contact.name)
put("enabled", contact.enabled)
}
array.put(obj)
}
prefs.edit { putString(KEY_CONTACTS, array.toString()) }
_contacts.value = list
}
private fun parseContacts(json: String): List<MonitoredContact> = try {
val array = JSONArray(json)
val list = mutableListOf<MonitoredContact>()
for (i in 0 until array.length()) {
val item = array.opt(i)
when (item) {
is JSONObject -> {
val num = normalize(item.optString("number"))
if (num.isNotBlank()) {
list.add(
MonitoredContact(
number = num,
name = item.optString("name", ""),
enabled = item.optBoolean("enabled", true),
)
)
}
}
is String -> {
// Migração legada onde salvava apenas array de strings
val num = normalize(item)
if (num.isNotBlank()) {
list.add(MonitoredContact(number = num, name = "", enabled = true))
}
}
}
}
list.distinctBy { it.number }
} catch (e: JSONException) {
emptyList()
}
}

View file

@ -0,0 +1,209 @@
package com.alarmezap.net
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.alarmezap.service.ConnectionStatus
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.util.concurrent.TimeUnit
import kotlin.math.min
/**
* Cliente WebSocket com reconexão automática (backoff exponencial).
* Todos os callbacks do [Listener] são entregues na main thread.
*/
class AlarmWebSocketClient(private val listener: Listener) {
interface Listener {
fun onConnectionStatus(status: ConnectionStatus, error: String?)
fun onConnected()
fun onTriggerAlarm(sender: String)
fun onWhatsAppStatus(status: String)
}
companion object {
private const val TAG = "AlarmWebSocketClient"
private const val NORMAL_CLOSURE = 1000
private const val POLICY_VIOLATION = 1008
private const val MAX_BACKOFF_MS = 30_000L
}
private val okHttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(20, TimeUnit.SECONDS)
.build()
private val main = Handler(Looper.getMainLooper())
private val reconnectRunnable = Runnable { open() }
private var socket: WebSocket? = null
private var url: HttpUrl? = null
private var shouldRun = false
private var attempt = 0
private var generation = 0
var status: ConnectionStatus = ConnectionStatus.DISCONNECTED
private set
fun connect(host: String, port: Int, token: String) {
shouldRun = true
val newUrl = buildUrl(host, port, token)
if (newUrl == null) {
main.removeCallbacks(reconnectRunnable)
closeSocket()
url = null
setStatus(ConnectionStatus.DISCONNECTED, "Endereço do servidor inválido. Configure IP e porta.")
return
}
if (newUrl == url && status != ConnectionStatus.DISCONNECTED) return
url = newUrl
attempt = 0
main.removeCallbacks(reconnectRunnable)
open()
}
/** Reconecta imediatamente se estiver desconectado (ex.: rede voltou). */
fun reconnectNow() {
if (!shouldRun || url == null || status != ConnectionStatus.DISCONNECTED) return
main.removeCallbacks(reconnectRunnable)
attempt = 0
open()
}
fun disconnect() {
shouldRun = false
main.removeCallbacks(reconnectRunnable)
closeSocket()
setStatus(ConnectionStatus.DISCONNECTED, null)
}
fun syncContacts(contacts: List<String>): Boolean {
val ws = socket ?: return false
if (status != ConnectionStatus.CONNECTED) return false
val payload = JSONObject()
.put("action", "sync_contacts")
.put("contacts", JSONArray(contacts))
.toString()
return ws.send(payload)
}
private fun closeSocket() {
generation++
socket?.close(NORMAL_CLOSURE, "client closing")
socket = null
}
private fun open() {
val target = url ?: return
closeSocket()
val gen = generation
setStatus(ConnectionStatus.CONNECTING, null)
Log.i(TAG, "Conectando a $target")
socket = okHttp.newWebSocket(Request.Builder().url(target).build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
main.post {
if (gen != generation) return@post
attempt = 0
setStatus(ConnectionStatus.CONNECTED, null)
listener.onConnected()
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
main.post {
if (gen != generation) return@post
handleMessage(text)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(NORMAL_CLOSURE, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
main.post {
if (gen != generation) return@post
socket = null
val msg = if (code == POLICY_VIOLATION) {
"Token recusado pelo servidor"
} else {
"Conexão encerrada pelo servidor ($code)"
}
setStatus(ConnectionStatus.DISCONNECTED, msg)
scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
main.post {
if (gen != generation) return@post
socket = null
Log.w(TAG, "Falha no WebSocket: ${t.message}")
setStatus(ConnectionStatus.DISCONNECTED, t.message ?: t.javaClass.simpleName)
scheduleReconnect()
}
}
})
}
private fun handleMessage(text: String) {
val json = try {
JSONObject(text)
} catch (e: JSONException) {
Log.w(TAG, "Mensagem não-JSON ignorada: $text")
return
}
when (json.optString("action")) {
"trigger_alarm" -> listener.onTriggerAlarm(json.optString("sender", "desconhecido"))
"hello", "pong" -> json.optString("whatsapp").takeIf { it.isNotEmpty() }
?.let(listener::onWhatsAppStatus)
"wa_status" -> json.optString("status").takeIf { it.isNotEmpty() }
?.let(listener::onWhatsAppStatus)
"contacts_synced" -> Log.i(TAG, "Servidor confirmou ${json.optInt("count")} contatos")
else -> Log.d(TAG, "Ação desconhecida: $text")
}
}
private fun scheduleReconnect() {
if (!shouldRun || url == null) return
val delayMs = min(MAX_BACKOFF_MS, 1000L shl attempt.coerceAtMost(5))
attempt++
Log.i(TAG, "Reconectando em ${delayMs}ms")
main.removeCallbacks(reconnectRunnable)
main.postDelayed(reconnectRunnable, delayMs)
}
private fun setStatus(newStatus: ConnectionStatus, error: String?) {
status = newStatus
listener.onConnectionStatus(newStatus, error)
}
private fun buildUrl(host: String, port: Int, token: String): HttpUrl? {
val clean = host.trim()
.substringAfter("://")
.substringBefore("/")
.substringBefore(":")
if (clean.isEmpty() || port !in 1..65535) return null
return try {
HttpUrl.Builder()
.scheme("http")
.host(clean)
.port(port)
.apply { if (token.isNotBlank()) addQueryParameter("token", token.trim()) }
.build()
} catch (e: IllegalArgumentException) {
null
}
}
}

View file

@ -0,0 +1,252 @@
package com.alarmezap.service
import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.alarmezap.alarm.AlarmController
import com.alarmezap.data.SettingsRepository
import com.alarmezap.net.AlarmWebSocketClient
import com.alarmezap.util.Notifications
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
/**
* Foreground Service que mantém o WebSocket ativo enquanto o sistema está armado,
* segura WakeLock (CPU) + WifiLock e dispara o alarme 1s após o comando "trigger_alarm".
*/
class AlarmMonitorService : Service(), AlarmWebSocketClient.Listener {
companion object {
private const val TAG = "AlarmMonitorService"
private const val TRIGGER_DELAY_MS = 1000L
private const val DEBOUNCE_DISMISS_WINDOW_MS = 15000L // 15 segundos de cooldown após dispensar alarme
fun start(context: Context) {
ContextCompat.startForegroundService(
context,
Intent(context, AlarmMonitorService::class.java),
)
}
fun stop(context: Context) {
context.stopService(Intent(context, AlarmMonitorService::class.java))
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var wsClient: AlarmWebSocketClient
private var cpuWakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var pendingTrigger: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
SettingsRepository.init(this)
enterForeground()
ServiceStatus.serviceRunning.value = true
acquireLocks()
wsClient = AlarmWebSocketClient(this)
registerNetworkCallback()
scope.launch {
SettingsRepository.server.collect { cfg -> wsClient.connect(cfg.host, cfg.port, cfg.token) }
}
scope.launch {
SettingsRepository.contacts.drop(1).collect {
wsClient.syncContacts(SettingsRepository.getEnabledNumbers())
}
}
scope.launch {
SettingsRepository.armed.collect { armed -> if (!armed) stopSelf() }
}
scope.launch {
combine(ServiceStatus.connection, ServiceStatus.whatsapp) { c, w -> statusText(c, w) }
.distinctUntilChanged()
.collect { text ->
Notifications.notify(
this@AlarmMonitorService,
Notifications.ID_SERVICE,
Notifications.buildServiceNotification(this@AlarmMonitorService, text),
)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
enterForeground()
if (!SettingsRepository.armed.value) {
stopSelf()
return START_NOT_STICKY
}
return START_STICKY
}
override fun onDestroy() {
pendingTrigger?.cancel()
scope.cancel()
if (::wsClient.isInitialized) wsClient.disconnect()
unregisterNetworkCallback()
releaseLocks()
ServiceStatus.serviceRunning.value = false
ServiceStatus.connection.value = ConnectionStatus.DISCONNECTED
ServiceStatus.whatsapp.value = null
super.onDestroy()
}
// ------------------------------------------------ WebSocket callbacks
override fun onConnectionStatus(status: ConnectionStatus, error: String?) {
ServiceStatus.connection.value = status
if (status == ConnectionStatus.CONNECTED) {
ServiceStatus.lastError.value = null
} else if (error != null) {
ServiceStatus.lastError.value = error
}
if (status != ConnectionStatus.CONNECTED) ServiceStatus.whatsapp.value = null
}
override fun onConnected() {
wsClient.syncContacts(SettingsRepository.getEnabledNumbers())
}
override fun onWhatsAppStatus(status: String) {
ServiceStatus.whatsapp.value = status
}
override fun onTriggerAlarm(sender: String) {
if (!SettingsRepository.armed.value) {
Log.i(TAG, "Disparo ignorado (sistema desarmado): $sender")
return
}
val enabledNumbers = SettingsRepository.getEnabledNumbers()
val normalizedSender = SettingsRepository.normalize(sender)
val isSenderActive = enabledNumbers.any { it == normalizedSender || it.endsWith(normalizedSender) || normalizedSender.endsWith(it) }
if (!isSenderActive) {
Log.i(TAG, "Disparo ignorado (contato desabilitado no app): $sender")
return
}
// Debounce: se o alarme já estiver tocando, ignorar novas mensagens
if (AlarmController.isRinging || pendingTrigger?.isActive == true) {
Log.i(TAG, "Alarme já em andamento/agendado; disparo de $sender ignorado")
return
}
// Debounce: se o alarme acabou de ser desligado/dispensado pelo usuário (ex: janela de 15 segundos)
val timeSinceDismissed = System.currentTimeMillis() - AlarmController.lastDismissedAt
if (timeSinceDismissed < DEBOUNCE_DISMISS_WINDOW_MS) {
Log.i(TAG, "Debounce ativo: alarme dispensado há ${timeSinceDismissed}ms (< ${DEBOUNCE_DISMISS_WINDOW_MS}ms). Disparo ignorado para $sender")
return
}
val senderName = SettingsRepository.getContactName(sender)
Log.i(TAG, "trigger_alarm recebido de $sender ($senderName); disparando em ${TRIGGER_DELAY_MS}ms")
pendingTrigger = scope.launch {
delay(TRIGGER_DELAY_MS)
if (SettingsRepository.armed.value) {
AlarmController.start(applicationContext, sender, senderName)
}
}
}
// ------------------------------------------------------------ Helpers
private fun statusText(connection: ConnectionStatus, whatsapp: String?): String =
"Servidor: ${connection.label} • WhatsApp: ${ServiceStatus.whatsappLabel(whatsapp)}"
private fun enterForeground() {
val notification = Notifications.buildServiceNotification(
this,
statusText(ServiceStatus.connection.value, ServiceStatus.whatsapp.value),
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
Notifications.ID_SERVICE,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(Notifications.ID_SERVICE, notification)
}
} catch (e: Exception) {
Log.e(TAG, "Não foi possível iniciar em primeiro plano", e)
stopSelf()
}
}
@SuppressLint("WakelockTimeout")
@Suppress("DEPRECATION")
private fun acquireLocks() {
val pm = getSystemService(PowerManager::class.java)
cpuWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AlarmeZap:Monitor").apply {
setReferenceCounted(false)
acquire()
}
val wm = applicationContext.getSystemService(WifiManager::class.java)
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wifiLock = wm?.createWifiLock(mode, "AlarmeZap:Wifi")?.apply {
setReferenceCounted(false)
acquire()
}
}
private fun releaseLocks() {
cpuWakeLock?.let { if (it.isHeld) runCatching { it.release() } }
cpuWakeLock = null
wifiLock?.let { if (it.isHeld) runCatching { it.release() } }
wifiLock = null
}
private fun registerNetworkCallback() {
val cm = getSystemService(ConnectivityManager::class.java) ?: return
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
mainHandler.post { if (::wsClient.isInitialized) wsClient.reconnectNow() }
}
}
try {
cm.registerDefaultNetworkCallback(callback)
networkCallback = callback
} catch (e: RuntimeException) {
Log.w(TAG, "Falha ao registrar callback de rede: ${e.message}")
}
}
private fun unregisterNetworkCallback() {
val callback = networkCallback ?: return
val cm = getSystemService(ConnectivityManager::class.java) ?: return
runCatching { cm.unregisterNetworkCallback(callback) }
networkCallback = null
}
}

View file

@ -0,0 +1,25 @@
package com.alarmezap.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.alarmezap.data.SettingsRepository
/** Religa o monitoramento após reiniciar o aparelho ou atualizar o app, se estiver armado. */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
intent.action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
SettingsRepository.init(context)
if (!SettingsRepository.armed.value) return
try {
AlarmMonitorService.start(context)
} catch (e: Exception) {
Log.e("BootReceiver", "Falha ao iniciar serviço após boot", e)
}
}
}

View file

@ -0,0 +1,25 @@
package com.alarmezap.service
import kotlinx.coroutines.flow.MutableStateFlow
enum class ConnectionStatus(val label: String) {
DISCONNECTED("Desconectado"),
CONNECTING("Conectando..."),
CONNECTED("Conectado"),
}
/** Estado de execução observado pela UI. */
object ServiceStatus {
val serviceRunning = MutableStateFlow(false)
val connection = MutableStateFlow(ConnectionStatus.DISCONNECTED)
val whatsapp = MutableStateFlow<String?>(null)
val lastError = MutableStateFlow<String?>(null)
fun whatsappLabel(status: String?): String = when (status) {
"open" -> "Conectado"
"connecting" -> "Conectando..."
"close" -> "Desconectado"
null -> "—"
else -> status
}
}

View file

@ -0,0 +1,180 @@
package com.alarmezap.ui
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.alarmezap.alarm.AlarmController
import com.alarmezap.util.Notifications
/** Tela cheia exibida sobre a tela de bloqueio enquanto o alarme toca. */
class AlarmActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
showOverLockScreen()
super.onCreate(savedInstanceState)
if (!AlarmController.isRinging) {
finish()
return
}
setContent {
val state by AlarmController.state.collectAsStateWithLifecycle()
LaunchedEffect(state.ringing) {
if (!state.ringing) finish()
}
AlarmScreen(
sender = state.sender,
senderName = state.senderName,
onStop = { AlarmController.stop(this@AlarmActivity) },
)
}
}
override fun onResume() {
super.onResume()
if (!AlarmController.isRinging) finish()
}
/** Impede que os botões de volume abaixem o alarme. */
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE -> true
else -> super.onKeyDown(keyCode, event)
}
@Suppress("DEPRECATION")
private fun showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
@Composable
private fun AlarmScreen(sender: String?, senderName: String?, onStop: () -> Unit) {
BackHandler(enabled = true) { }
val transition = rememberInfiniteTransition(label = "pulse")
val background by transition.animateColor(
initialValue = Color(0xFF8E0000),
targetValue = Color(0xFFFF1744),
animationSpec = infiniteRepeatable(tween(durationMillis = 600), RepeatMode.Reverse),
label = "background",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(background)
.systemBarsPadding()
.padding(24.dp),
) {
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "🚨", fontSize = 96.sp)
Text(
text = "ALARME!",
color = Color.White,
fontSize = 52.sp,
fontWeight = FontWeight.Black,
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Mensagem de", color = Color.White, fontSize = 20.sp)
if (!senderName.isNullOrBlank()) {
Text(
text = senderName,
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
)
Text(
text = Notifications.formatSender(sender),
color = Color.White.copy(alpha = 0.85f),
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
} else {
Text(
text = Notifications.formatSender(sender),
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
Button(
onClick = onStop,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(bottom = 24.dp)
.height(180.dp),
shape = RoundedCornerShape(32.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFFB71C1C),
),
) {
Text(
text = "DESLIGAR\nALARME",
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
)
}
}
}

View file

@ -0,0 +1,585 @@
package com.alarmezap.ui
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.ContactsContract
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.alarmezap.data.AddContactResult
import com.alarmezap.data.MonitoredContact
import com.alarmezap.data.ServerConfig
import com.alarmezap.data.SettingsRepository
import com.alarmezap.service.AlarmMonitorService
import com.alarmezap.service.ConnectionStatus
import com.alarmezap.service.ServiceStatus
import com.alarmezap.ui.theme.AlarmeZapTheme
import com.alarmezap.util.Notifications
import com.alarmezap.util.PermissionHelper
import com.alarmezap.util.PermissionStatus
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
AlarmeZapTheme {
MainScreen(
onToggleArmed = { armed ->
SettingsRepository.setArmed(armed)
if (armed) {
AlarmMonitorService.start(this)
} else {
AlarmMonitorService.stop(this)
}
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(onToggleArmed: (Boolean) -> Unit) {
val context = LocalContext.current
val armed by SettingsRepository.armed.collectAsStateWithLifecycle()
val serverConfig by SettingsRepository.server.collectAsStateWithLifecycle()
val contacts by SettingsRepository.contacts.collectAsStateWithLifecycle()
val connection by ServiceStatus.connection.collectAsStateWithLifecycle()
val whatsappStatus by ServiceStatus.whatsapp.collectAsStateWithLifecycle()
val lastError by ServiceStatus.lastError.collectAsStateWithLifecycle()
var hostInput by remember(serverConfig.host) { mutableStateOf(serverConfig.host) }
var portInput by remember(serverConfig.port) { mutableStateOf(serverConfig.port.toString()) }
var tokenInput by remember(serverConfig.token) { mutableStateOf(serverConfig.token) }
var newContactNumber by remember { mutableStateOf("") }
var newContactName by remember { mutableStateOf("") }
var permissions by remember { mutableStateOf(PermissionHelper.check(context)) }
fun refreshPermissions() {
permissions = PermissionHelper.check(context)
}
LaunchedEffect(Unit) {
refreshPermissions()
}
// Picker para selecionar contato da agenda do telefone
val contactPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickContact()
) { contactUri: Uri? ->
if (contactUri != null) {
val contentResolver = context.contentResolver
var pickedName = ""
var pickedNumber = ""
// 1. Pega o ID e o Nome de exibição
contentResolver.query(
contactUri,
arrayOf(ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.HAS_PHONE_NUMBER),
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idIndex = cursor.getColumnIndex(ContactsContract.Contacts._ID)
val nameIndex = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
val hasPhoneIndex = cursor.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER)
val contactId = if (idIndex >= 0) cursor.getString(idIndex) else ""
if (nameIndex >= 0) {
pickedName = cursor.getString(nameIndex) ?: ""
}
val hasPhone = if (hasPhoneIndex >= 0) cursor.getInt(hasPhoneIndex) > 0 else false
// 2. Busca os telefones do contato
if (hasPhone && contactId.isNotBlank()) {
contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
arrayOf(contactId),
null
)?.use { phoneCursor ->
if (phoneCursor.moveToFirst()) {
val numberIndex = phoneCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
if (numberIndex >= 0) {
pickedNumber = phoneCursor.getString(numberIndex) ?: ""
}
}
}
}
}
}
if (pickedNumber.isNotBlank()) {
val digitsOnly = SettingsRepository.normalize(pickedNumber)
newContactNumber = digitsOnly
if (pickedName.isNotBlank()) {
newContactName = pickedName
}
Toast.makeText(context, "Contato carregado: $pickedName", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Nenhum número de telefone encontrado para este contato", Toast.LENGTH_SHORT).show()
}
}
}
// Launcher de permissão para ler contatos
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
contactPickerLauncher.launch(null)
} else {
Toast.makeText(context, "Permissão para ler contatos negada", Toast.LENGTH_SHORT).show()
}
}
fun pickFromPhoneContacts() {
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
contactPickerLauncher.launch(null)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("AlarmeZap", fontWeight = FontWeight.Bold)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Cartão de Armar / Desarmar
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (armed) Color(0xFFFFEBEE) else Color(0xFFF5F5F5)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (armed) "SISTEMA ARMADO" else "SISTEMA DESARMADO",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = if (armed) Color(0xFFD32F2F) else Color(0xFF757575)
)
Text(
text = if (armed) "Monitorando mensagens em segundo plano" else "Disparos desativados",
fontSize = 13.sp,
color = Color.Gray
)
}
Switch(
checked = armed,
onCheckedChange = { isChecked ->
if (isChecked && serverConfig.host.isBlank()) {
Toast.makeText(context, "Configure o IP do servidor antes de armar!", Toast.LENGTH_SHORT).show()
} else {
onToggleArmed(isChecked)
}
}
)
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(12.dp))
// Status da Conexão
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Servidor:", fontSize = 14.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
val dotColor = when (connection) {
ConnectionStatus.CONNECTED -> Color(0xFF2E7D32)
ConnectionStatus.CONNECTING -> Color(0xFFF57C00)
ConnectionStatus.DISCONNECTED -> Color(0xFFC62828)
}
Box(
modifier = Modifier
.size(10.dp)
.background(dotColor, CircleShape)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = connection.label,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "WhatsApp Web:", fontSize = 14.sp)
Text(
text = ServiceStatus.whatsappLabel(whatsappStatus),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (whatsappStatus == "open") Color(0xFF2E7D32) else Color.Gray
)
}
if (!lastError.isNullOrBlank()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Erro: $lastError",
fontSize = 12.sp,
color = Color(0xFFC62828)
)
}
}
}
}
// Alertas de Permissões críticas
item {
AnimatedVisibility(visible = !permissions.allGranted) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF3E0)),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
text = "⚠️ Permissões Necessárias para Alarme Pesado:",
fontWeight = FontWeight.Bold,
color = Color(0xFFE65100)
)
Spacer(modifier = Modifier.height(6.dp))
if (!permissions.batteryUnrestricted) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Ignorar Otimização de Bateria", fontSize = 13.sp)
TextButton(onClick = {
PermissionHelper.open(context, PermissionHelper.batterySettings(context))
}) { Text("Ativar") }
}
}
if (!permissions.overlay) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Sobrepor outros apps (Tela cheia)", fontSize = 13.sp)
TextButton(onClick = {
PermissionHelper.open(context, PermissionHelper.overlaySettings(context))
}) { Text("Ativar") }
}
}
if (!permissions.dndAccess) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Acesso a Não Perturbe (Tocar no mudo)", fontSize = 13.sp)
TextButton(onClick = {
PermissionHelper.open(context, PermissionHelper.dndSettings())
}) { Text("Ativar") }
}
}
}
}
}
}
// Configuração do Servidor
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Configuração do Servidor WebSocket",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = hostInput,
onValueChange = { hostInput = it },
label = { Text("IP do Servidor Linux (ex: 192.168.1.100)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = portInput,
onValueChange = { portInput = it },
label = { Text("Porta") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = tokenInput,
onValueChange = { tokenInput = it },
label = { Text("Token (Opcional)") },
singleLine = true,
modifier = Modifier.weight(1.5f)
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val port = portInput.toIntOrNull() ?: 8080
SettingsRepository.setServer(
ServerConfig(
host = hostInput.trim(),
port = port,
token = tokenInput.trim()
)
)
Toast.makeText(context, "Configurações salvas!", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.align(Alignment.End)
) {
Text("Salvar Servidor")
}
}
}
}
// Contatos Monitorados
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Contatos Monitorados (${contacts.size})",
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
text = "Ative ou desative o alarme para cada contato",
fontSize = 12.sp,
color = Color.Gray
)
}
OutlinedButton(
onClick = { pickFromPhoneContacts() },
shape = RoundedCornerShape(8.dp)
) {
Text("📖 Agenda")
}
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = newContactName,
onValueChange = { newContactName = it },
label = { Text("Nome do Contato (Ex: Mãe, Chefe, Portaria)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newContactNumber,
onValueChange = { newContactNumber = it },
label = { Text("DDI + DDD + Número") },
placeholder = { Text("ex: 5511999999999") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (newContactNumber.isNotBlank()) {
when (SettingsRepository.addContact(newContactNumber, newContactName)) {
AddContactResult.ADDED -> {
newContactNumber = ""
newContactName = ""
}
AddContactResult.INVALID -> Toast.makeText(context, "Número inválido", Toast.LENGTH_SHORT).show()
AddContactResult.DUPLICATE -> Toast.makeText(context, "Número já cadastrado", Toast.LENGTH_SHORT).show()
}
}
}
)
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (newContactNumber.isNotBlank()) {
when (SettingsRepository.addContact(newContactNumber, newContactName)) {
AddContactResult.ADDED -> {
newContactNumber = ""
newContactName = ""
}
AddContactResult.INVALID -> Toast.makeText(context, "Número inválido", Toast.LENGTH_SHORT).show()
AddContactResult.DUPLICATE -> Toast.makeText(context, "Número já cadastrado", Toast.LENGTH_SHORT).show()
}
}
}
) {
Text("Adicionar")
}
}
}
}
}
// Lista dinâmica de contatos
items(contacts, key = { it.number }) { contact ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (contact.enabled) MaterialTheme.colorScheme.surfaceVariant else Color(0xFFEEEEEE)
),
shape = RoundedCornerShape(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Switch(
checked = contact.enabled,
onCheckedChange = { enabled ->
SettingsRepository.toggleContact(contact.number, enabled)
}
)
Spacer(modifier = Modifier.width(12.dp))
Column {
if (contact.name.isNotBlank()) {
Text(
text = contact.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = if (contact.enabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Gray
)
}
Text(
text = Notifications.formatSender(contact.number),
fontSize = if (contact.name.isNotBlank()) 13.sp else 16.sp,
fontWeight = if (contact.name.isNotBlank()) FontWeight.Normal else FontWeight.Bold,
color = if (contact.enabled) {
if (contact.name.isNotBlank()) Color.Gray else MaterialTheme.colorScheme.onSurfaceVariant
} else Color.LightGray
)
}
}
TextButton(
onClick = { SettingsRepository.removeContact(contact.number) },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Excluir")
}
}
}
}
}
}
}

View file

@ -0,0 +1,24 @@
package com.alarmezap.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun AlarmeZapTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val context = LocalContext.current
val scheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(colorScheme = scheme, content = content)
}

View file

@ -0,0 +1,136 @@
package com.alarmezap.util
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.alarmezap.R
import com.alarmezap.alarm.StopAlarmReceiver
import com.alarmezap.ui.AlarmActivity
import com.alarmezap.ui.MainActivity
object Notifications {
const val CHANNEL_SERVICE = "monitor_service"
const val CHANNEL_ALARM = "alarm_trigger"
const val ID_SERVICE = 1001
const val ID_ALARM = 1002
fun createChannels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
val service = NotificationChannel(
CHANNEL_SERVICE,
"Monitoramento ativo",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Notificação persistente enquanto o sistema está armado"
setShowBadge(false)
}
val alarm = NotificationChannel(
CHANNEL_ALARM,
"Alarme disparado",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Alerta em tela cheia quando um contato monitorado envia mensagem"
setSound(null, null)
enableVibration(false)
setBypassDnd(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannels(listOf(service, alarm))
}
fun canPost(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
fun buildServiceNotification(context: Context, text: String): Notification {
val open = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(context, CHANNEL_SERVICE)
.setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle("AlarmeZap armado")
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setContentIntent(open)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
fun alarmActivityIntent(context: Context): Intent =
Intent(context, AlarmActivity::class.java).addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION,
)
fun buildAlarmNotification(context: Context, sender: String, senderName: String? = null): Notification {
val fullScreen = PendingIntent.getActivity(
context,
1,
alarmActivityIntent(context),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stop = PendingIntent.getBroadcast(
context,
2,
Intent(context, StopAlarmReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val displayName = formatSender(sender, senderName)
return NotificationCompat.Builder(context, CHANNEL_ALARM)
.setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle("🚨 ALARME DISPARADO")
.setContentText("Mensagem de $displayName")
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setFullScreenIntent(fullScreen, true)
.setContentIntent(fullScreen)
.addAction(0, "DESLIGAR ALARME", stop)
.build()
}
@SuppressLint("MissingPermission")
fun notify(context: Context, id: Int, notification: Notification) {
if (canPost(context)) {
NotificationManagerCompat.from(context).notify(id, notification)
}
}
fun cancel(context: Context, id: Int) {
NotificationManagerCompat.from(context).cancel(id)
}
fun formatSender(sender: String?, name: String? = null): String {
val formattedNumber = when {
sender.isNullOrBlank() -> "desconhecido"
sender.all(Char::isDigit) -> "+$sender"
else -> sender
}
return if (!name.isNullOrBlank()) {
"$name ($formattedNumber)"
} else {
formattedNumber
}
}
}

View file

@ -0,0 +1,80 @@
package com.alarmezap.util
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.app.NotificationManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
data class PermissionStatus(
val notifications: Boolean,
val fullScreenIntent: Boolean,
val batteryUnrestricted: Boolean,
val overlay: Boolean,
val dndAccess: Boolean,
) {
val allGranted: Boolean
get() = notifications && fullScreenIntent && batteryUnrestricted && overlay && dndAccess
}
object PermissionHelper {
fun check(context: Context): PermissionStatus {
val nm = context.getSystemService(NotificationManager::class.java)
val pm = context.getSystemService(PowerManager::class.java)
return PermissionStatus(
notifications = Notifications.canPost(context) &&
NotificationManagerCompat.from(context).areNotificationsEnabled(),
fullScreenIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
nm.canUseFullScreenIntent()
} else {
true
},
batteryUnrestricted = pm.isIgnoringBatteryOptimizations(context.packageName),
overlay = Settings.canDrawOverlays(context),
dndAccess = nm.isNotificationPolicyAccessGranted,
)
}
private fun packageUri(context: Context) = "package:${context.packageName}".toUri()
fun notificationSettings(context: Context): Intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
fun fullScreenIntentSettings(context: Context): Intent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, packageUri(context))
} else {
appDetails(context)
}
@SuppressLint("BatteryLife")
fun batterySettings(context: Context): Intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri(context))
fun overlaySettings(context: Context): Intent =
Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri(context))
fun dndSettings(): Intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
fun appDetails(context: Context): Intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri(context))
fun open(context: Context, intent: Intent) {
try {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: ActivityNotFoundException) {
try {
context.startActivity(appDetails(context).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (ignored: ActivityNotFoundException) {
// Nenhuma tela de configurações disponível neste aparelho.
}
}
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="2"
android:scaleY="2"
android:translateX="30"
android:translateY="30">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7.58,4.08L6.15,2.65C3.75,4.48 2.17,7.3 2.03,10.5h2c0.15,-2.65 1.51,-4.97 3.55,-6.420zM19.97,10.5h2c-0.15,-3.2 -1.73,-6.02 -4.12,-7.85l-1.42,1.43c2.02,1.45 3.39,3.770 3.54,6.42zM18,11c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2v-5zM12,22c0.14,0 0.27,-0.01 0.4,-0.04 0.65,-0.14 1.18,-0.58 1.44,-1.18 0.1,-0.24 0.15,-0.5 0.15,-0.78h-4c0.01,1.1 0.9,2 2.01,2z" />
</group>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7.58,4.08L6.15,2.65C3.75,4.48 2.17,7.3 2.03,10.5h2c0.15,-2.65 1.51,-4.97 3.55,-6.42zM19.97,10.5h2c-0.15,-3.2 -1.73,-6.02 -4.12,-7.85l-1.42,1.43c2.02,1.45 3.39,3.77 3.54,6.42zM18,11c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2v-5zM12,22c0.14,0 0.27,-0.01 0.4,-0.04 0.65,-0.14 1.18,-0.58 1.44,-1.18 0.1,-0.24 0.15,-0.5 0.15,-0.78h-4c0.01,1.1 0.9,2 2.01,2z" />
</vector>

View file

@ -0,0 +1,6 @@
<?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="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?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="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">AlarmeZap</string>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="alarm_red">#FFB71C1C</color>
<color name="ic_launcher_background">#FFC62828</color>
<style name="Theme.AlarmeZap" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.AlarmeZap.Alarm" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/alarm_red</item>
<item name="android:statusBarColor">@color/alarm_red</item>
<item name="android:navigationBarColor">@color/alarm_red</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "9.4.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

View file

@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/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\n' "$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, 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" "$@"

94
android/gradlew.bat vendored Normal file
View file

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

View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AlarmeZap"
include(":app")

BIN
dist/alarmezap.apk vendored Normal file

Binary file not shown.

20
server/.env.example Normal file
View file

@ -0,0 +1,20 @@
# Porta e interface do servidor WebSocket
PORT=8080
HOST=0.0.0.0
# Token opcional. Se definido, o app precisa informar o mesmo token.
AUTH_TOKEN=
# Ignorar mensagens enviadas em grupos (true/false)
IGNORE_GROUPS=false
# Ignora mensagens mais antigas que N segundos (evita disparos de mensagens offline antigas). 0 = desativa
MAX_MESSAGE_AGE_SECONDS=300
# Pastas/arquivos de dados
AUTH_DIR=auth_info
CONTACTS_FILE=contacts.json
# Níveis de log: trace | debug | info | warn | error | silent
LOG_LEVEL=info
BAILEYS_LOG_LEVEL=silent

438
server/index.js Normal file
View file

@ -0,0 +1,438 @@
/**
* AlarmeZap - Servidor ponte WhatsApp -> WebSocket
*
* - Conecta ao WhatsApp Web via Baileys (QR Code no terminal, sessão persistida em disco).
* - Mantém um servidor WebSocket ao qual o app Android se conecta.
* - O app envia a lista de números monitorados; o servidor a mantém sincronizada (e persistida).
* - Quando chega mensagem de um número monitorado, envia {"action":"trigger_alarm","sender":"..."}.
*/
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { WebSocketServer, WebSocket } from 'ws';
import pino from 'pino';
import pretty from 'pino-pretty';
import qrcode from 'qrcode-terminal';
import makeWASocket, {
Browsers,
DisconnectReason,
fetchLatestBaileysVersion,
isJidBroadcast,
isJidGroup,
isJidStatusBroadcast,
isLidUser,
isPnUser,
jidNormalizedUser,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
// ---------------------------------------------------------------------------
// Configuração
// ---------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '8080', 10);
const HOST = process.env.HOST || '0.0.0.0';
const AUTH_TOKEN = (process.env.AUTH_TOKEN || '').trim();
const AUTH_DIR = path.resolve(__dirname, process.env.AUTH_DIR || 'auth_info');
const CONTACTS_FILE = path.resolve(__dirname, process.env.CONTACTS_FILE || 'contacts.json');
const IGNORE_GROUPS = String(process.env.IGNORE_GROUPS || 'false').toLowerCase() === 'true';
const MAX_MESSAGE_AGE_SECONDS = parseInt(process.env.MAX_MESSAGE_AGE_SECONDS || '300', 10);
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
const HEARTBEAT_MS = 30_000;
const log = pino(
{ level: LOG_LEVEL },
pretty({ colorize: true, sync: true, translateTime: 'SYS:dd/mm/yyyy HH:MM:ss', ignore: 'pid,hostname' }),
);
const baileysLogger = pino({ level: process.env.BAILEYS_LOG_LEVEL || 'silent' });
// ---------------------------------------------------------------------------
// Lista de contatos monitorados (sincronizada pelo app, persistida em disco)
// ---------------------------------------------------------------------------
/** @type {Set<string>} */
let monitored = new Set();
function normalizeNumber(raw) {
return String(raw ?? '').replace(/\D/g, '');
}
/**
* Gera variantes equivalentes de um número. Trata o "nono dígito" brasileiro:
* contas antigas no WhatsApp podem estar registradas sem o 9 (55 DD XXXXXXXX).
*/
function numberVariants(num) {
const n = normalizeNumber(num);
const out = new Set();
if (!n) return out;
out.add(n);
if (n.startsWith('55')) {
if (n.length === 13 && n[4] === '9') {
out.add(n.slice(0, 4) + n.slice(5));
} else if (n.length === 12 && /[6-9]/.test(n[4])) {
out.add(n.slice(0, 4) + '9' + n.slice(4));
}
}
return out;
}
function isMonitored(number) {
for (const v of numberVariants(number)) {
if (monitored.has(v)) return true;
}
return false;
}
function loadContacts() {
try {
if (fs.existsSync(CONTACTS_FILE)) {
const data = JSON.parse(fs.readFileSync(CONTACTS_FILE, 'utf8'));
if (Array.isArray(data)) {
monitored = new Set(data.map(normalizeNumber).filter(Boolean));
}
}
} catch (err) {
log.warn({ err: err.message }, 'Falha ao ler contacts.json; iniciando com lista vazia');
}
log.info({ contacts: [...monitored] }, `Contatos monitorados carregados: ${monitored.size}`);
}
function saveContacts() {
try {
const tmp = `${CONTACTS_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify([...monitored], null, 2));
fs.renameSync(tmp, CONTACTS_FILE);
} catch (err) {
log.error({ err: err.message }, 'Falha ao salvar contacts.json');
}
}
function setContacts(list) {
monitored = new Set((Array.isArray(list) ? list : []).map(normalizeNumber).filter((n) => n.length >= 8));
saveContacts();
log.info({ contacts: [...monitored] }, `Lista de monitorados sincronizada: ${monitored.size}`);
}
// ---------------------------------------------------------------------------
// Servidor WebSocket
// ---------------------------------------------------------------------------
let waStatus = 'connecting';
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
function send(ws, payload) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function broadcast(payload) {
let delivered = 0;
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN && client.authenticated) {
client.send(JSON.stringify(payload));
delivered++;
}
}
return delivered;
}
const wss = new WebSocketServer({ host: HOST, port: PORT });
wss.on('listening', () => {
log.info(`Servidor WebSocket ouvindo em ws://${HOST}:${PORT}${AUTH_TOKEN ? ' (token obrigatório)' : ''}`);
});
wss.on('error', (err) => {
log.fatal({ err: err.message }, 'Erro no servidor WebSocket');
process.exit(1);
});
wss.on('connection', (ws, req) => {
const remote = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
if (AUTH_TOKEN) {
const url = new URL(req.url || '/', 'http://localhost');
const token = url.searchParams.get('token') || req.headers['x-auth-token'] || '';
if (!safeEqual(token, AUTH_TOKEN)) {
log.warn({ remote }, 'Cliente rejeitado: token inválido');
ws.close(1008, 'invalid token');
return;
}
}
ws.authenticated = true;
ws.isAlive = true;
log.info({ remote, clients: wss.clients.size }, 'Cliente conectado');
send(ws, { action: 'hello', whatsapp: waStatus, contacts: [...monitored] });
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data) => {
ws.isAlive = true;
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
log.warn({ remote }, 'Mensagem inválida (não é JSON)');
return;
}
switch (msg?.action) {
case 'sync_contacts':
setContacts(msg.contacts);
send(ws, { action: 'contacts_synced', count: monitored.size, contacts: [...monitored] });
break;
case 'ping':
send(ws, { action: 'pong', whatsapp: waStatus });
break;
default:
log.debug({ remote, msg }, 'Ação desconhecida');
}
});
ws.on('close', (code) => {
log.info({ remote, code, clients: wss.clients.size }, 'Cliente desconectado');
});
ws.on('error', (err) => {
log.warn({ remote, err: err.message }, 'Erro no socket do cliente');
});
});
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
log.warn('Cliente sem resposta ao heartbeat; encerrando conexão');
ws.terminate();
continue;
}
ws.isAlive = false;
try {
ws.ping();
} catch {
ws.terminate();
}
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(heartbeat));
// ---------------------------------------------------------------------------
// WhatsApp (Baileys)
// ---------------------------------------------------------------------------
let sock = null;
let reconnectAttempts = 0;
let reconnectTimer = null;
let shuttingDown = false;
function setWaStatus(status) {
waStatus = status;
broadcast({ action: 'wa_status', status });
}
function jidToNumber(jid) {
if (!jid) return null;
const user = jidNormalizedUser(jid).split('@')[0].split(':')[0];
return normalizeNumber(user) || null;
}
/** Resolve o número de telefone (PN) do remetente, lidando com endereçamento LID. */
async function resolveSenderNumber(key) {
const group = isJidGroup(key.remoteJid);
const candidates = group
? [key.participantAlt, key.participant]
: [key.remoteJidAlt, key.remoteJid, key.participantAlt, key.participant];
for (const jid of candidates) {
if (jid && isPnUser(jid)) return jidToNumber(jid);
}
for (const jid of candidates) {
if (jid && isLidUser(jid)) {
try {
const pn = await sock?.signalRepository?.lidMapping?.getPNForLID(jid);
if (pn) return jidToNumber(pn);
} catch (err) {
log.debug({ err: err.message, jid }, 'Falha ao resolver LID -> PN');
}
}
}
return null;
}
function messageTimestampSeconds(msg) {
const ts = msg.messageTimestamp;
if (ts == null) return null;
if (typeof ts === 'number') return ts;
if (typeof ts === 'object' && typeof ts.toNumber === 'function') return ts.toNumber();
const n = Number(ts);
return Number.isFinite(n) ? n : null;
}
async function handleIncomingMessage(msg) {
const key = msg.key;
if (!key || key.fromMe) return;
if (!key.remoteJid || isJidStatusBroadcast(key.remoteJid) || isJidBroadcast(key.remoteJid)) return;
if (IGNORE_GROUPS && isJidGroup(key.remoteJid)) return;
if (!msg.message || msg.message.protocolMessage) return;
const ts = messageTimestampSeconds(msg);
if (ts && MAX_MESSAGE_AGE_SECONDS > 0 && Date.now() / 1000 - ts > MAX_MESSAGE_AGE_SECONDS) {
log.debug({ id: key.id }, 'Mensagem antiga ignorada');
return;
}
const sender = await resolveSenderNumber(key);
if (!sender) {
log.debug({ key }, 'Não foi possível identificar o número do remetente');
return;
}
if (!isMonitored(sender)) {
log.debug({ sender }, 'Mensagem de contato não monitorado');
return;
}
const delivered = broadcast({ action: 'trigger_alarm', sender });
if (delivered > 0) {
log.info({ sender, delivered }, '🚨 Alarme disparado');
} else {
log.warn({ sender }, 'Remetente monitorado, mas nenhum celular conectado ao WebSocket!');
}
}
function scheduleReconnect(delayMs) {
if (shuttingDown) return;
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
startWhatsApp().catch((err) => {
log.error({ err: err.message }, 'Erro ao reconectar ao WhatsApp');
scheduleReconnect(Math.min(60_000, 2_000 * 2 ** reconnectAttempts++));
});
}, delayMs);
}
async function startWhatsApp() {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
let version;
try {
({ version } = await fetchLatestBaileysVersion());
} catch {
version = undefined; // usa a versão embutida no Baileys
}
setWaStatus('connecting');
sock = makeWASocket({
version,
logger: baileysLogger,
browser: Browsers.ubuntu('AlarmeZap'),
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, baileysLogger),
},
markOnlineOnConnect: false,
syncFullHistory: false,
generateHighQualityLinkPreview: false,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
log.info('Escaneie o QR Code abaixo no WhatsApp > Aparelhos conectados > Conectar aparelho:');
qrcode.generate(qr, { small: true });
}
if (connection === 'open') {
reconnectAttempts = 0;
setWaStatus('open');
log.info({ user: sock?.user?.id }, '✅ Conectado ao WhatsApp');
}
if (connection === 'close') {
setWaStatus('close');
const statusCode = lastDisconnect?.error?.output?.statusCode;
const reason = lastDisconnect?.error?.message;
if (statusCode === DisconnectReason.loggedOut) {
log.warn('Sessão encerrada pelo WhatsApp (logout). Apagando credenciais e gerando novo QR...');
try {
fs.rmSync(AUTH_DIR, { recursive: true, force: true });
} catch (err) {
log.error({ err: err.message }, 'Falha ao apagar pasta de autenticação');
}
reconnectAttempts = 0;
scheduleReconnect(1_000);
return;
}
if (statusCode === DisconnectReason.restartRequired) {
log.info('Reinício solicitado pelo WhatsApp (normal após parear). Reconectando...');
scheduleReconnect(0);
return;
}
const delay = Math.min(60_000, 2_000 * 2 ** reconnectAttempts++);
log.warn({ statusCode, reason }, `Conexão com WhatsApp fechada. Reconectando em ${delay / 1000}s...`);
scheduleReconnect(delay);
}
});
sock.ev.on('messages.upsert', async ({ messages, type }) => {
if (type !== 'notify') return;
for (const msg of messages) {
try {
await handleIncomingMessage(msg);
} catch (err) {
log.error({ err: err.message }, 'Erro ao processar mensagem');
}
}
});
}
// ---------------------------------------------------------------------------
// Inicialização e encerramento
// ---------------------------------------------------------------------------
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log.info(`Recebido ${signal}. Encerrando...`);
clearTimeout(reconnectTimer);
clearInterval(heartbeat);
try {
sock?.end(undefined);
} catch {
/* ignorado */
}
for (const client of wss.clients) client.close(1001, 'server shutdown');
wss.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3_000).unref();
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('unhandledRejection', (err) => {
log.error({ err: err?.message || String(err) }, 'Promise rejeitada sem tratamento');
});
loadContacts();
startWhatsApp().catch((err) => {
log.error({ err: err.message }, 'Falha ao iniciar WhatsApp');
scheduleReconnect(5_000);
});

1561
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
server/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "alarmezap-server",
"version": "1.0.0",
"description": "Ponte WhatsApp (Baileys) -> WebSocket que dispara alarme no celular Android",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc14",
"dotenv": "^16.4.5",
"pino": "^9.5.0",
"pino-pretty": "^13.2.0",
"qrcode-terminal": "^0.12.0",
"ws": "^8.18.0"
}
}