feat: initial commit for AlarmeZap
This commit is contained in:
commit
9579607826
37 changed files with 4737 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
75
README.md
Normal 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**.
|
||||
54
android/app/build.gradle.kts
Normal file
54
android/app/build.gradle.kts
Normal 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
3
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-dontwarn org.bouncycastle.jsse.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.openjsse.**
|
||||
85
android/app/src/main/AndroidManifest.xml
Normal file
85
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
13
android/app/src/main/java/com/alarmezap/AlarmeZapApp.kt
Normal file
13
android/app/src/main/java/com/alarmezap/AlarmeZapApp.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
301
android/app/src/main/java/com/alarmezap/alarm/AlarmController.kt
Normal file
301
android/app/src/main/java/com/alarmezap/alarm/AlarmController.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
180
android/app/src/main/java/com/alarmezap/ui/AlarmActivity.kt
Normal file
180
android/app/src/main/java/com/alarmezap/ui/AlarmActivity.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
585
android/app/src/main/java/com/alarmezap/ui/MainActivity.kt
Normal file
585
android/app/src/main/java/com/alarmezap/ui/MainActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
android/app/src/main/java/com/alarmezap/ui/theme/Theme.kt
Normal file
24
android/app/src/main/java/com/alarmezap/ui/theme/Theme.kt
Normal 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)
|
||||
}
|
||||
136
android/app/src/main/java/com/alarmezap/util/Notifications.kt
Normal file
136
android/app/src/main/java/com/alarmezap/util/Notifications.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
15
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_stat_alarm.xml
Normal file
10
android/app/src/main/res/drawable/ic_stat_alarm.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">AlarmeZap</string>
|
||||
</resources>
|
||||
13
android/app/src/main/res/values/themes.xml
Normal file
13
android/app/src/main/res/values/themes.xml
Normal 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
4
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
12
android/gradle/gradle-daemon-jvm.properties
Normal file
12
android/gradle/gradle-daemon-jvm.properties
Normal 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
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
android/gradlew
vendored
Executable 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
94
android/gradlew.bat
vendored
Normal 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
|
||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal 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
BIN
dist/alarmezap.apk
vendored
Normal file
Binary file not shown.
20
server/.env.example
Normal file
20
server/.env.example
Normal 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
438
server/index.js
Normal 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
1561
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
server/package.json
Normal file
21
server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue