diff --git a/README.md b/README.md index b6a40666..daa19f06 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ SMS Gateway turns your Android smartphone into an SMS gateway. It's a lightweigh - 💳 **Multiple SIM card support:** Supports devices with [multiple SIM cards](https://docs.sms-gate.app/features/multi-sim/). - 📱📱 **Multiple device support:** Connect [multiple devices](https://docs.sms-gate.app/features/multi-device/) to the same account with Cloud or Private server. Messages sent via the server are distributed across all connected devices. - 💾 **Data SMS support:** Send and receive binary [data payloads](https://docs.sms-gate.app/features/data-sms.md) via SMS for IoT commands, encrypted messages, and other specialized use cases. +- 🕐 **Working hours scheduling:** Restrict message delivery to configurable time windows, automatically pausing the queue outside of them 🔌 Integration: diff --git a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt index 35a8f235..77d23caa 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt @@ -135,17 +135,27 @@ class MessagesService( throw ConflictException() } - if (scheduleAt != null - && scheduleAt > System.currentTimeMillis() - && scheduleAt < (nextScheduled ?: 0) - ) { - SendMessagesWorker.start(context, true, scheduleAt) + val workHoursStart = if (priority < Message.PRIORITY_EXPEDITED) { + settings.nextWorkHoursStart() } else { + null + } + val startTime = when { + workHoursStart != null -> workHoursStart + scheduleAt != null + && scheduleAt > System.currentTimeMillis() + && scheduleAt < (nextScheduled ?: 0) -> scheduleAt + else -> SendMessagesWorker.IMMEDIATE + } + + if (startTime == SendMessagesWorker.IMMEDIATE) { SendMessagesWorker.start( context, nextScheduled == null || priority >= Message.PRIORITY_EXPEDITED, SendMessagesWorker.IMMEDIATE ) + } else { + SendMessagesWorker.start(context, true, startTime) } return message @@ -254,7 +264,7 @@ class MessagesService( val priority = message.params.priority ?: Message.PRIORITY_DEFAULT - // apply limits if: + // apply rate limit if: // - message is not expedited // - message is expedited and previous message had higher or equal priority if (priority < Message.PRIORITY_EXPEDITED @@ -263,6 +273,11 @@ class MessagesService( applyLimit() } + // skip work hours for expedited messages + if (priority < Message.PRIORITY_EXPEDITED) { + applyWorkHoursLimit() + } + if (!withContext(NonCancellable) { sendMessage(message) }) { // if message was not sent - don't need any delay before next message continue @@ -305,6 +320,16 @@ class MessagesService( delay(settings.limitPeriod.duration - (System.currentTimeMillis() - processedStats.lastTimestamp) + 1000L) } + private suspend fun applyWorkHoursLimit() { + val nextStart = settings.nextWorkHoursStart() ?: return + + val delayMs = nextStart - System.currentTimeMillis() + if (delayMs > 0) { + SendMessagesWorker.start(context, true, nextStart) + delay(delayMs) + } + } + /** * @return `true` if message was sent */ diff --git a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesSettings.kt b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesSettings.kt index d0a6d79b..58ed7643 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesSettings.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesSettings.kt @@ -61,6 +61,76 @@ class MessagesSettings( val processingOrder: ProcessingOrder get() = storage.get(PROCESSING_ORDER) ?: ProcessingOrder.LIFO + val workHoursEnabled: Boolean + get() = storage.get(WORK_HOURS_ENABLED) ?: false + val workHoursStart: String + get() = storage.get(WORK_HOURS_START) ?: "09:00" + val workHoursEnd: String + get() = storage.get(WORK_HOURS_END) ?: "19:00" + + private fun isInWorkHours(): Boolean { + if (!workHoursEnabled) return true + + val now = java.util.Calendar.getInstance() + val nowMinutes = + now.get(java.util.Calendar.HOUR_OF_DAY) * 60 + now.get(java.util.Calendar.MINUTE) + val startMinutes = parseTimeMinutes(workHoursStart) + val endMinutes = parseTimeMinutes(workHoursEnd) + + if (startMinutes == endMinutes) return true + + return if (startMinutes < endMinutes) { + nowMinutes in startMinutes until endMinutes + } else { + nowMinutes >= startMinutes || nowMinutes < endMinutes + } + } + + fun nextWorkHoursStart(): Long? { + if (!workHoursEnabled) return null + if (isInWorkHours()) return null + + val now = java.util.Calendar.getInstance() + val nowMinutes = + now.get(java.util.Calendar.HOUR_OF_DAY) * 60 + now.get(java.util.Calendar.MINUTE) + val startMinutes = parseTimeMinutes(workHoursStart) + val endMinutes = parseTimeMinutes(workHoursEnd) + + val todayStart = java.util.Calendar.getInstance().apply { + set(java.util.Calendar.HOUR_OF_DAY, startMinutes / 60) + set(java.util.Calendar.MINUTE, startMinutes % 60) + set(java.util.Calendar.SECOND, 0) + set(java.util.Calendar.MILLISECOND, 0) + } + + return if (startMinutes <= endMinutes) { + if (nowMinutes < startMinutes) { + todayStart.timeInMillis + } else { + todayStart.add(java.util.Calendar.DAY_OF_MONTH, 1) + todayStart.timeInMillis + } + } else { + todayStart.timeInMillis + } + } + + private fun parseTimeMinutes(value: String): Int { + val parts = value.split(":") + return parts.getOrNull(0)?.toIntOrNull()?.let { h -> + h * 60 + (parts.getOrNull(1)?.toIntOrNull() ?: 0) + } ?: 0 + } + + private fun isValidTimeFormat(value: String): Boolean { + val regex = Regex("^\\d{2}:\\d{2}$") + if (!regex.matches(value)) return false + val parts = value.split(":") + val h = parts.getOrNull(0)?.toIntOrNull() ?: return false + val m = parts.getOrNull(1)?.toIntOrNull() ?: return false + return h in 0..23 && m in 0..59 + } + init { migrate() } @@ -94,6 +164,10 @@ class MessagesSettings( private const val LOG_LIFETIME_DAYS = "log_lifetime_days" private const val PROCESSING_ORDER = "processing_order" + + private const val WORK_HOURS_ENABLED = "work_hours_enabled" + private const val WORK_HOURS_START = "work_hours_start" + private const val WORK_HOURS_END = "work_hours_end" } override fun export(): Map { @@ -105,6 +179,9 @@ class MessagesSettings( SIM_SELECTION_MODE to simSelectionMode.name, LOG_LIFETIME_DAYS to logLifetimeDays, PROCESSING_ORDER to processingOrder.name, + WORK_HOURS_ENABLED to workHoursEnabled, + WORK_HOURS_START to workHoursStart, + WORK_HOURS_END to workHoursEnd, ) } @@ -192,6 +269,41 @@ class MessagesSettings( changed } + WORK_HOURS_ENABLED -> { + val newValue = value?.toString()?.toBooleanStrictOrNull() ?: false + val changed = this.workHoursEnabled != newValue + + storage.set(key, newValue.toString()) + + changed + } + + WORK_HOURS_START -> { + val raw = value?.toString() + val newValue = if (raw != null && isValidTimeFormat(raw)) raw else "09:00" + if (raw != null && !isValidTimeFormat(raw)) { + throw IllegalArgumentException("Invalid time format: $raw") + } + val changed = this.workHoursStart != newValue + + storage.set(key, newValue) + + changed + } + + WORK_HOURS_END -> { + val raw = value?.toString() + val newValue = if (raw != null && isValidTimeFormat(raw)) raw else "19:00" + if (raw != null && !isValidTimeFormat(raw)) { + throw IllegalArgumentException("Invalid time format: $raw") + } + val changed = this.workHoursEnd != newValue + + storage.set(key, newValue) + + changed + } + else -> false } }.any { it } diff --git a/app/src/main/java/me/capcom/smsgateway/ui/settings/MessagesSettingsFragment.kt b/app/src/main/java/me/capcom/smsgateway/ui/settings/MessagesSettingsFragment.kt index 58a9e9c9..bb613d99 100644 --- a/app/src/main/java/me/capcom/smsgateway/ui/settings/MessagesSettingsFragment.kt +++ b/app/src/main/java/me/capcom/smsgateway/ui/settings/MessagesSettingsFragment.kt @@ -1,5 +1,6 @@ package me.capcom.smsgateway.ui.settings +import android.app.TimePickerDialog import android.content.SharedPreferences import android.os.Bundle import android.text.InputType @@ -45,6 +46,25 @@ class MessagesSettingsFragment : BasePreferenceFragment() { it.selectAll() } } + + "messages.work_hours_start", "messages.work_hours_end" -> { + val pref = preference as EditTextPreference + val currentText = pref.text ?: "09:00" + val parts = currentText.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 9 + val minute = parts.getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0 + + TimePickerDialog( + requireContext(), + { _, h, m -> + pref.text = String.format(java.util.Locale.US, "%02d:%02d", h, m) + }, + hour, + minute, + true + ).show() + return + } } super.onDisplayPreferenceDialog(preference) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72adfc8f..912d2e6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,4 +181,9 @@ Language System default Password must not be empty + Working Hours + Enable working hours + Only send messages within the defined time window + Start time + End time \ No newline at end of file diff --git a/app/src/main/res/xml/messages_preferences.xml b/app/src/main/res/xml/messages_preferences.xml index 0f48f1b6..87b9d222 100644 --- a/app/src/main/res/xml/messages_preferences.xml +++ b/app/src/main/res/xml/messages_preferences.xml @@ -59,4 +59,27 @@ app:title="@string/messages_count" app:useSimpleSummaryProvider="true" /> + + + + + + \ No newline at end of file