package com.vanta.smsgateway import android.app.* import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder import android.telephony.SmsManager import androidx.core.app.NotificationCompat import kotlinx.coroutines.* class SmsService : Service() { companion object { const val CHANNEL_ID = "sms_gw_chan" const val NOTIF_ID = 1 const val ACTION_STOP = "STOP" const val PREF_NAME = "sms_gw" const val PREF_URL = "base_url" const val PREF_KEY = "api_key" const val ACTION_SENT = "com.vanta.smsgateway.SMS_SENT" const val ACTION_DELIVERED = "com.vanta.smsgateway.SMS_DELIVERED" @Volatile var isRunning = false } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private lateinit var api: ApiClient private var sent = 0 override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate(); createChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { stopSelf(); return START_NOT_STICKY } val prefs = getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) val url = prefs.getString(PREF_URL, "") ?: "" val key = prefs.getString(PREF_KEY, "") ?: "" if (url.isEmpty() || key.isEmpty()) { stopSelf(); return START_NOT_STICKY } api = ApiClient(url, key) isRunning = true startForeground(NOTIF_ID, buildNotif("Starting...")) launchLoops() return START_STICKY } override fun onDestroy() { isRunning = false scope.cancel() super.onDestroy() } private fun launchLoops() { // Main send loop — unchanged behavior scope.launch { while (isActive) { try { val result = api.fetchQueue() if (result.jobs.isEmpty()) { updateNotif("Idle — sent: $sent") delay(5_000) continue } for (job in result.jobs) { updateNotif("Sending to ${job.toNumber}...") processJob(job) randomWait(result.delayMinSec, result.delayMaxSec) } } catch (e: Exception) { updateNotif("Error: ${e.message?.take(50)}") delay(10_000) } } } // Heartbeat loop — unchanged scope.launch { val info = "${Build.MANUFACTURER} ${Build.MODEL} / Android ${Build.VERSION.RELEASE}" while (isActive) { try { api.sendHeartbeat(info) } catch (_: Exception) {} delay(30_000) } } } private suspend fun randomWait(minSec: Long, maxSec: Long) { val waitSec = if (minSec >= maxSec) minSec else (minSec..maxSec).random() if (waitSec <= 0) return updateNotif("Idle — sent: $sent") delay(waitSec * 1000L) } private fun processJob(job: SmsJob) { val subId = SubscriptionHelper.getESimSubId(this) try { val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { getSystemService(SmsManager::class.java).createForSubscriptionId(subId) } else { @Suppress("DEPRECATION") SmsManager.getSmsManagerForSubscriptionId(subId) } val parts = smsManager.divideMessage(job.message) // Build sent PendingIntent — fires SmsSentReceiver val sentIntent = Intent(ACTION_SENT).apply { setClass(this@SmsService, SmsSentReceiver::class.java) putExtra("job_id", job.id) putExtra("part_idx", 0) putExtra("part_total", 1) } val sentPi = PendingIntent.getBroadcast( this, job.id.toInt(), sentIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) // Build delivered PendingIntent — fires SmsDeliveredReceiver val deliveredIntent = Intent(ACTION_DELIVERED).apply { setClass(this@SmsService, SmsDeliveredReceiver::class.java) putExtra("job_id", job.id) } val deliveredPi = PendingIntent.getBroadcast( this, (job.id + 1_000_000L).toInt(), deliveredIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) if (parts.size == 1) { smsManager.sendTextMessage(job.toNumber, null, job.message, sentPi, deliveredPi) } else { // Multipart — one sentPi per part, deliveredPi on last part only val sentParts = ArrayList() val deliveredParts = ArrayList() for (i in parts.indices) { val sp = Intent(ACTION_SENT).apply { setClass(this@SmsService, SmsSentReceiver::class.java) putExtra("job_id", job.id) putExtra("part_idx", i) putExtra("part_total", parts.size) } sentParts.add(PendingIntent.getBroadcast( this, (job.id + i).toInt(), sp, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT )) deliveredParts.add(if (i == parts.size - 1) deliveredPi else null) } smsManager.sendMultipartTextMessage(job.toNumber, null, parts, sentParts, deliveredParts) } // Don't call api.reportReceipt here — SmsSentReceiver handles it now sent++ } catch (e: Exception) { // Direct failure — report immediately CoroutineScope(Dispatchers.IO).launch { api.reportReceipt(job.id, "failed", e.message ?: "send failed") } } } private fun createChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel(CHANNEL_ID, "SMS Gateway", NotificationManager.IMPORTANCE_LOW) .apply { description = "Gateway running indicator" } .also { getSystemService(NotificationManager::class.java).createNotificationChannel(it) } } } private fun buildNotif(text: String): Notification { val stopPi = PendingIntent.getService( this, 0, Intent(this, SmsService::class.java).apply { action = ACTION_STOP }, PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("SMS Gateway") .setContentText(text) .setSmallIcon(android.R.drawable.ic_dialog_info) .addAction(android.R.drawable.ic_delete, "Stop", stopPi) .setOngoing(true) .build() } private fun updateNotif(text: String) = getSystemService(NotificationManager::class.java).notify(NOTIF_ID, buildNotif(text)) }