chore: vendor capcom6/android-sms-gateway upstream (Apache-2.0 baseline fork)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package me.capcom.smsgateway
|
||||
|
||||
import android.app.Application
|
||||
import healthModule
|
||||
import me.capcom.smsgateway.data.dbModule
|
||||
import me.capcom.smsgateway.modules.connection.connectionModule
|
||||
import me.capcom.smsgateway.modules.encryption.encryptionModule
|
||||
import me.capcom.smsgateway.modules.events.eventBusModule
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayService
|
||||
import me.capcom.smsgateway.modules.incoming.incomingModule
|
||||
import me.capcom.smsgateway.modules.localserver.localserverModule
|
||||
import me.capcom.smsgateway.modules.logs.logsModule
|
||||
import me.capcom.smsgateway.modules.messages.messagesModule
|
||||
import me.capcom.smsgateway.modules.notifications.notificationsModule
|
||||
import me.capcom.smsgateway.modules.orchestrator.OrchestratorService
|
||||
import me.capcom.smsgateway.modules.orchestrator.orchestratorModule
|
||||
import me.capcom.smsgateway.modules.ping.pingModule
|
||||
import me.capcom.smsgateway.modules.receiver.receiverModule
|
||||
import me.capcom.smsgateway.modules.settings.settingsModule
|
||||
import me.capcom.smsgateway.modules.webhooks.webhooksModule
|
||||
import me.capcom.smsgateway.receivers.EventsReceiver
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
|
||||
class App: Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
startKoin {
|
||||
androidLogger()
|
||||
androidContext(this@App)
|
||||
modules(
|
||||
eventBusModule,
|
||||
settingsModule,
|
||||
dbModule,
|
||||
logsModule,
|
||||
notificationsModule,
|
||||
messagesModule,
|
||||
incomingModule,
|
||||
receiverModule,
|
||||
encryptionModule,
|
||||
me.capcom.smsgateway.modules.gateway.gatewayModule,
|
||||
healthModule,
|
||||
webhooksModule,
|
||||
localserverModule,
|
||||
pingModule,
|
||||
connectionModule,
|
||||
orchestratorModule,
|
||||
)
|
||||
}
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
GlobalExceptionHandler(
|
||||
Thread.getDefaultUncaughtExceptionHandler()!!,
|
||||
get()
|
||||
)
|
||||
)
|
||||
|
||||
instance = this
|
||||
|
||||
EventsReceiver.register(this)
|
||||
|
||||
get<OrchestratorService>().start(this, true)
|
||||
}
|
||||
|
||||
val gatewayService: GatewayService by inject()
|
||||
|
||||
companion object {
|
||||
lateinit var instance: App
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package me.capcom.smsgateway
|
||||
|
||||
import android.util.Log
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.lang.Thread.UncaughtExceptionHandler
|
||||
|
||||
class GlobalExceptionHandler(
|
||||
private val defaultHandler: UncaughtExceptionHandler,
|
||||
private val logger: LogsService
|
||||
) : UncaughtExceptionHandler, KoinComponent {
|
||||
|
||||
override fun uncaughtException(thread: Thread, throwable: Throwable) {
|
||||
try {
|
||||
logger.insert(
|
||||
LogEntry.Priority.ERROR,
|
||||
"GlobalExceptionHandler",
|
||||
"Unhandled exception in ${thread.name}",
|
||||
mapOf(
|
||||
"message" to throwable.message,
|
||||
"stackTrace" to throwable.stackTrace.joinToString("\n"),
|
||||
"threadName" to thread.name,
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("GlobalExceptionHandler", "Failed to log uncaught exception", e)
|
||||
} finally {
|
||||
defaultHandler.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package me.capcom.smsgateway
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import me.capcom.smsgateway.databinding.ActivityMainBinding
|
||||
import me.capcom.smsgateway.ui.HolderFragment
|
||||
import me.capcom.smsgateway.ui.HomeFragment
|
||||
import me.capcom.smsgateway.ui.SettingsFragment
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val adapter = FragmentsAdapter(this)
|
||||
binding.viewPager.adapter = adapter
|
||||
|
||||
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
|
||||
when (position) {
|
||||
0 -> tab.apply {
|
||||
text = getString(R.string.tab_text_home)
|
||||
setIcon(R.drawable.ic_home)
|
||||
}
|
||||
|
||||
1 -> tab.apply {
|
||||
text = getString(R.string.tab_text_messages)
|
||||
setIcon(R.drawable.ic_sms)
|
||||
}
|
||||
|
||||
2 -> tab.apply {
|
||||
text = getString(R.string.tab_text_settings)
|
||||
setIcon(R.drawable.ic_advanced)
|
||||
}
|
||||
}
|
||||
}.attach()
|
||||
|
||||
processIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
processIntent(intent)
|
||||
}
|
||||
|
||||
private fun processIntent(intent: Intent) {
|
||||
val tabIndex = intent.getIntExtra(EXTRA_TAB_INDEX, TAB_INDEX_HOME)
|
||||
|
||||
binding.viewPager.currentItem = tabIndex
|
||||
}
|
||||
|
||||
class FragmentsAdapter(activity: AppCompatActivity) :
|
||||
androidx.viewpager2.adapter.FragmentStateAdapter(activity) {
|
||||
|
||||
override fun getItemCount(): Int = 3
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> HomeFragment.newInstance()
|
||||
1 -> HolderFragment.newInstance()
|
||||
else -> SettingsFragment.newInstance()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAB_INDEX_HOME = 0
|
||||
const val TAB_INDEX_MESSAGES = 1
|
||||
const val TAB_INDEX_SETTINGS = 2
|
||||
|
||||
private const val EXTRA_TAB_INDEX = "tabIndex"
|
||||
|
||||
fun starter(context: Context, tabIndex: Int): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(EXTRA_TAB_INDEX, tabIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package me.capcom.smsgateway.data
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import me.capcom.smsgateway.data.dao.MessagesDao
|
||||
import me.capcom.smsgateway.data.dao.TokensDao
|
||||
import me.capcom.smsgateway.data.entities.Message
|
||||
import me.capcom.smsgateway.data.entities.MessageRecipient
|
||||
import me.capcom.smsgateway.data.entities.MessageState
|
||||
import me.capcom.smsgateway.data.entities.RecipientState
|
||||
import me.capcom.smsgateway.data.entities.Token
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessage
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessagesDao
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntriesDao
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import me.capcom.smsgateway.modules.webhooks.db.WebHook
|
||||
import me.capcom.smsgateway.modules.webhooks.db.WebHooksDao
|
||||
import me.capcom.smsgateway.modules.webhooks.db.WebhookQueueDao
|
||||
import me.capcom.smsgateway.modules.webhooks.db.WebhookQueueEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
Message::class,
|
||||
MessageRecipient::class,
|
||||
RecipientState::class,
|
||||
MessageState::class,
|
||||
WebHook::class,
|
||||
WebhookQueueEntity::class,
|
||||
LogEntry::class,
|
||||
Token::class,
|
||||
IncomingMessage::class,
|
||||
],
|
||||
version = 20,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 1, to = 2),
|
||||
AutoMigration(from = 2, to = 3),
|
||||
AutoMigration(from = 3, to = 4),
|
||||
AutoMigration(from = 4, to = 5),
|
||||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7),
|
||||
// AutoMigration(from = 7, to = 8), // manual migration
|
||||
AutoMigration(from = 8, to = 9),
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12),
|
||||
AutoMigration(from = 12, to = 13),
|
||||
// AutoMigration(from = 13, to = 14), // manual migration
|
||||
AutoMigration(from = 14, to = 15),
|
||||
AutoMigration(from = 15, to = 16),
|
||||
AutoMigration(from = 16, to = 17),
|
||||
AutoMigration(from = 17, to = 18),
|
||||
AutoMigration(from = 18, to = 19),
|
||||
AutoMigration(from = 19, to = 20),
|
||||
]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun messagesDao(): MessagesDao
|
||||
abstract fun webhooksDao(): WebHooksDao
|
||||
abstract fun webhookQueueDao(): WebhookQueueDao
|
||||
abstract fun logDao(): LogEntriesDao
|
||||
abstract fun incomingMessagesDao(): IncomingMessagesDao
|
||||
abstract fun tokensDao(): TokensDao
|
||||
|
||||
companion object {
|
||||
fun getDatabase(context: android.content.Context): AppDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"gateway"
|
||||
)
|
||||
.addMigrations(
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_13_14,
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package me.capcom.smsgateway.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonElement
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class Converters {
|
||||
private val gson = GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create()
|
||||
|
||||
@TypeConverter
|
||||
fun listToString(value: List<String>?): String? {
|
||||
return value?.let { gson.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun listFromString(value: String?): List<String>? {
|
||||
return value?.let { gson.fromJson(it, Array<String>::class.java).toList() }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToString(value: Date?): String? {
|
||||
return value?.let { DATE_FORMAT.format(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateFromString(value: String?): Date? {
|
||||
return value?.let { DATE_FORMAT.parse(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToString(value: JsonElement?): String? {
|
||||
return value?.let { gson.toJson(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun stringToJson(value: String?): JsonElement? {
|
||||
return value?.let { gson.fromJson(it, JsonElement::class.java) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.capcom.smsgateway.data
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE message
|
||||
SET validUntil = strftime('%FT%TZ', createdAt / 1000 + 86400, 'unixepoch')
|
||||
WHERE validUntil IS NULL AND state = 'Pending'
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_13_14 = object : Migration(13, 14) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Create a new table with the desired schema (without the text column)
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_new (
|
||||
`id` TEXT NOT NULL,
|
||||
`withDeliveryReport` INTEGER NOT NULL DEFAULT 1,
|
||||
`simNumber` INTEGER,
|
||||
`validUntil` TEXT,
|
||||
`isEncrypted` INTEGER NOT NULL DEFAULT 0,
|
||||
`skipPhoneValidation` INTEGER NOT NULL DEFAULT 0,
|
||||
`priority` INTEGER NOT NULL DEFAULT 0,
|
||||
`type` TEXT NOT NULL DEFAULT 'Text',
|
||||
`source` TEXT NOT NULL DEFAULT 'Local',
|
||||
`content` TEXT NOT NULL,
|
||||
`state` TEXT NOT NULL,
|
||||
`createdAt` INTEGER NOT NULL DEFAULT 0,
|
||||
`processedAt` INTEGER,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Copy data from the old table to the new table
|
||||
database.execSQL(
|
||||
"""
|
||||
INSERT INTO message_new (
|
||||
id,
|
||||
withDeliveryReport,
|
||||
simNumber,
|
||||
validUntil,
|
||||
isEncrypted,
|
||||
skipPhoneValidation,
|
||||
priority,
|
||||
source,
|
||||
type,
|
||||
content,
|
||||
state,
|
||||
createdAt,
|
||||
processedAt
|
||||
) SELECT
|
||||
id,
|
||||
withDeliveryReport,
|
||||
simNumber,
|
||||
validUntil,
|
||||
isEncrypted,
|
||||
skipPhoneValidation,
|
||||
priority,
|
||||
source,
|
||||
'Text' AS type,
|
||||
'{"text": "' || replace(replace(text, '\\', '\\\\'), '"', '\\"') || '"}' AS content,
|
||||
state,
|
||||
createdAt,
|
||||
processedAt
|
||||
FROM message
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
// Drop the old table and rename the new table to the original name
|
||||
database.execSQL("DROP TABLE IF EXISTS message")
|
||||
database.execSQL("ALTER TABLE message_new RENAME TO message")
|
||||
|
||||
// Create indices
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_Message_state ON message (state)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_Message_createdAt ON message (createdAt)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_Message_processedAt ON message (processedAt)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package me.capcom.smsgateway.data
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val dbModule = module {
|
||||
single { AppDatabase.getDatabase(get()) }
|
||||
single { get<AppDatabase>().messagesDao() }
|
||||
single { get<AppDatabase>().incomingMessagesDao() }
|
||||
single { get<AppDatabase>().webhooksDao() }
|
||||
single { get<AppDatabase>().webhookQueueDao() }
|
||||
single { get<AppDatabase>().logDao() }
|
||||
single { get<AppDatabase>().tokensDao() }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package me.capcom.smsgateway.data.dao
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import me.capcom.smsgateway.data.entities.Message
|
||||
import me.capcom.smsgateway.data.entities.MessageRecipient
|
||||
import me.capcom.smsgateway.data.entities.MessageState
|
||||
import me.capcom.smsgateway.data.entities.MessageWithRecipients
|
||||
import me.capcom.smsgateway.data.entities.MessagesStats
|
||||
import me.capcom.smsgateway.data.entities.MessagesTotals
|
||||
import me.capcom.smsgateway.data.entities.RecipientState
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.domain.ProcessingState
|
||||
|
||||
@Dao
|
||||
interface MessagesDao {
|
||||
//#region Read
|
||||
@Query("SELECT COUNT(*) as count, MAX(processedAt) as lastTimestamp FROM message WHERE state <> 'Pending' AND state <> 'Failed' AND processedAt >= :timestamp")
|
||||
fun countProcessedFrom(timestamp: Long): MessagesStats
|
||||
|
||||
@Query("SELECT COUNT(*) as count, MAX(processedAt) as lastTimestamp FROM message WHERE state = 'Failed' AND processedAt >= :timestamp")
|
||||
fun countFailedFrom(timestamp: Long): MessagesStats
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN state = 'Pending' THEN 1 ELSE 0 END), 0) as pending,
|
||||
COALESCE(SUM(CASE WHEN state = 'Sent' THEN 1 ELSE 0 END), 0) as sent,
|
||||
COALESCE(SUM(CASE WHEN state = 'Delivered' THEN 1 ELSE 0 END), 0) as delivered,
|
||||
COALESCE(SUM(CASE WHEN state = 'Failed' THEN 1 ELSE 0 END), 0) as failed
|
||||
FROM message
|
||||
"""
|
||||
)
|
||||
fun getMessagesStats(): LiveData<MessagesTotals>
|
||||
|
||||
@Query("SELECT * FROM message ORDER BY createdAt DESC LIMIT :limit")
|
||||
fun selectLast(limit: Int): LiveData<List<Message>>
|
||||
|
||||
/**
|
||||
* FIFO: oldest pending first (priority DESC, createdAt ASC)
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT *, `rowid` FROM message WHERE state = 'Pending' ORDER BY priority DESC, createdAt ASC LIMIT 1")
|
||||
fun getPendingFifo(): MessageWithRecipients?
|
||||
|
||||
/**
|
||||
* LIFO: newest pending first (priority DESC, createdAt DESC)
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT *, `rowid` FROM message WHERE state = 'Pending' ORDER BY priority DESC, createdAt DESC LIMIT 1")
|
||||
fun getPendingLifo(): MessageWithRecipients?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT *, `rowid` FROM message WHERE id = :id")
|
||||
fun get(id: String): MessageWithRecipients?
|
||||
|
||||
/**
|
||||
* Count messages based on state and date range
|
||||
*/
|
||||
@Query("SELECT COUNT(*) as count FROM message WHERE source = :source AND (:state IS NULL OR state = :state) AND createdAt BETWEEN :start AND :end")
|
||||
fun count(source: EntitySource, state: ProcessingState?, start: Long, end: Long): Int
|
||||
|
||||
/**
|
||||
* Get messages with pagination and filtering
|
||||
*/
|
||||
@Transaction
|
||||
@Query("SELECT *, `rowid` FROM message WHERE source = :source AND (:state IS NULL OR state = :state) AND createdAt BETWEEN :start AND :end ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
|
||||
fun select(
|
||||
source: EntitySource,
|
||||
state: ProcessingState?,
|
||||
start: Long,
|
||||
end: Long,
|
||||
limit: Int,
|
||||
offset: Int
|
||||
): List<MessageWithRecipients>
|
||||
//#endregion
|
||||
|
||||
@Insert
|
||||
fun _insert(message: Message)
|
||||
|
||||
@Insert
|
||||
fun _insertRecipients(recipient: List<MessageRecipient>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun _insertMessageState(state: MessageState)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun _insertRecipientStates(state: List<RecipientState>)
|
||||
|
||||
@Query(
|
||||
"INSERT INTO recipientstate(messageId, phoneNumber, state, updatedAt) " +
|
||||
"SELECT :messageId, phoneNumber, :state, strftime('%s', 'now') * 1000 " +
|
||||
"FROM messagerecipient " +
|
||||
"WHERE messageId = :messageId"
|
||||
)
|
||||
fun _insertRecipientStatesByMessage(
|
||||
messageId: String,
|
||||
state: me.capcom.smsgateway.domain.ProcessingState
|
||||
)
|
||||
|
||||
@Transaction
|
||||
fun insert(message: MessageWithRecipients) {
|
||||
_insert(message.message)
|
||||
_insertMessageState(
|
||||
MessageState(
|
||||
message.message.id,
|
||||
message.message.state,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
_insertRecipients(message.recipients)
|
||||
_insertRecipientStates(message.recipients.map {
|
||||
RecipientState(
|
||||
message.message.id,
|
||||
it.phoneNumber,
|
||||
it.state,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Query("UPDATE message SET state = :state WHERE id = :id AND state <> 'Failed'")
|
||||
fun _updateMessageState(id: String, state: me.capcom.smsgateway.domain.ProcessingState)
|
||||
|
||||
fun updateMessageState(id: String, state: me.capcom.smsgateway.domain.ProcessingState) {
|
||||
_updateMessageState(id, state)
|
||||
_insertMessageState(
|
||||
MessageState(
|
||||
id,
|
||||
state,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Query("UPDATE message SET state = 'Processed', processedAt = strftime('%s', 'now') * 1000 WHERE id = :id")
|
||||
fun _setMessageProcessed(id: String)
|
||||
fun setMessageProcessed(id: String) {
|
||||
_setMessageProcessed(id)
|
||||
_insertMessageState(
|
||||
MessageState(
|
||||
id,
|
||||
me.capcom.smsgateway.domain.ProcessingState.Processed,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Query("UPDATE messagerecipient SET state = :state, error = :error WHERE messageId = :id AND phoneNumber = :phoneNumber AND state <> 'Failed'")
|
||||
fun _updateRecipientState(
|
||||
id: String,
|
||||
phoneNumber: String,
|
||||
state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
error: String?
|
||||
)
|
||||
|
||||
@Transaction
|
||||
fun updateRecipientState(
|
||||
id: String,
|
||||
phoneNumber: String,
|
||||
state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
error: String?
|
||||
) {
|
||||
_updateRecipientState(id, phoneNumber, state, error)
|
||||
_insertRecipientStates(
|
||||
listOf(
|
||||
RecipientState(id, phoneNumber, state, System.currentTimeMillis())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Query("UPDATE messagerecipient SET state = :state, error = :error WHERE messageId = :id AND state <> 'Failed'")
|
||||
fun _updateRecipientsState(
|
||||
id: String,
|
||||
state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
error: String?
|
||||
)
|
||||
|
||||
@Transaction
|
||||
fun updateRecipientsState(
|
||||
id: String,
|
||||
state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
error: String?
|
||||
) {
|
||||
_updateRecipientsState(id, state, error)
|
||||
_insertRecipientStatesByMessage(id, state)
|
||||
}
|
||||
|
||||
@Query("UPDATE message SET simNumber = :simNumber WHERE id = :id")
|
||||
fun updateSimNumber(
|
||||
id: String,
|
||||
simNumber: Int
|
||||
)
|
||||
|
||||
@Query("UPDATE message SET partsCount = :partsCount WHERE id = :id")
|
||||
fun updatePartsCount(id: String, partsCount: Int)
|
||||
|
||||
@Query("DELETE FROM message WHERE createdAt < :until AND state <> 'Pending'")
|
||||
suspend fun truncateLog(until: Long)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.capcom.smsgateway.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import me.capcom.smsgateway.data.entities.Token
|
||||
|
||||
@Dao
|
||||
interface TokensDao {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insert(token: Token): Long
|
||||
|
||||
@Query("UPDATE tokens SET revokedAt = strftime('%s', 'now') * 1000 WHERE id = :id")
|
||||
suspend fun revoke(id: String)
|
||||
|
||||
@Query("SELECT EXISTS (SELECT 1 FROM tokens WHERE id = :id AND revokedAt IS NOT NULL)")
|
||||
suspend fun isRevoked(id: String): Boolean
|
||||
|
||||
@Query("DELETE FROM tokens WHERE expiresAt < strftime('%s', 'now') * 1000")
|
||||
suspend fun cleanup()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.Gson
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.domain.MessageContent
|
||||
import me.capcom.smsgateway.domain.ProcessingState
|
||||
import java.util.Date
|
||||
|
||||
enum class MessageType {
|
||||
Text,
|
||||
Data,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
indices = [
|
||||
androidx.room.Index(value = ["createdAt"]),
|
||||
androidx.room.Index(value = ["state", "processedAt"]),
|
||||
androidx.room.Index(value = ["state", "createdAt"]),
|
||||
]
|
||||
)
|
||||
data class Message constructor(
|
||||
@PrimaryKey val id: String,
|
||||
|
||||
@ColumnInfo(defaultValue = "1")
|
||||
val withDeliveryReport: Boolean,
|
||||
val simNumber: Int?,
|
||||
val validUntil: Date?,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val isEncrypted: Boolean,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val skipPhoneValidation: Boolean,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val priority: Byte,
|
||||
|
||||
@ColumnInfo(defaultValue = "Local")
|
||||
val source: EntitySource,
|
||||
|
||||
@ColumnInfo(defaultValue = "Text")
|
||||
val type: MessageType = MessageType.Text,
|
||||
|
||||
val content: String,
|
||||
|
||||
val state: ProcessingState = ProcessingState.Pending,
|
||||
val partsCount: Int? = null,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val processedAt: Long? = null,
|
||||
) {
|
||||
constructor(
|
||||
id: String,
|
||||
withDeliveryReport: Boolean,
|
||||
simNumber: Int?,
|
||||
validUntil: Date?,
|
||||
isEncrypted: Boolean,
|
||||
skipPhoneValidation: Boolean,
|
||||
priority: Byte,
|
||||
source: EntitySource,
|
||||
|
||||
content: MessageContent,
|
||||
|
||||
createdAt: Long,
|
||||
) : this(
|
||||
id,
|
||||
withDeliveryReport,
|
||||
simNumber,
|
||||
validUntil,
|
||||
isEncrypted,
|
||||
skipPhoneValidation,
|
||||
priority,
|
||||
source,
|
||||
|
||||
content = gson.toJson(content),
|
||||
type = when (content) {
|
||||
is MessageContent.Text -> MessageType.Text
|
||||
is MessageContent.Data -> MessageType.Data
|
||||
},
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
val textContent: MessageContent.Text?
|
||||
get() = when (type) {
|
||||
MessageType.Text -> gson.fromJson(content, MessageContent.Text::class.java)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val dataContent: MessageContent.Data?
|
||||
get() = when (type) {
|
||||
MessageType.Data -> gson.fromJson(content, MessageContent.Data::class.java)
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PRIORITY_MIN: Byte = Byte.MIN_VALUE
|
||||
const val PRIORITY_DEFAULT: Byte = 0
|
||||
const val PRIORITY_EXPEDITED: Byte = 100
|
||||
|
||||
private val gson = Gson()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["messageId", "phoneNumber"],
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Message::class, parentColumns = ["id"], childColumns = ["messageId"], onDelete = ForeignKey.CASCADE)
|
||||
]
|
||||
)
|
||||
data class MessageRecipient(
|
||||
val messageId: String,
|
||||
val phoneNumber: String,
|
||||
val state: me.capcom.smsgateway.domain.ProcessingState = me.capcom.smsgateway.domain.ProcessingState.Pending,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["messageId", "state"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Message::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["messageId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class MessageState(
|
||||
val messageId: String,
|
||||
val state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
val updatedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class MessageWithRecipients(
|
||||
@Embedded val message: Message,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "messageId",
|
||||
)
|
||||
val recipients: List<MessageRecipient>,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "messageId",
|
||||
)
|
||||
val states: List<MessageState> = emptyList(),
|
||||
@ColumnInfo(name = "rowid")
|
||||
val rowId: Long = 0,
|
||||
) {
|
||||
val state: me.capcom.smsgateway.domain.ProcessingState
|
||||
get() = when {
|
||||
recipients.any { it.state == me.capcom.smsgateway.domain.ProcessingState.Pending } -> me.capcom.smsgateway.domain.ProcessingState.Pending
|
||||
recipients.any { it.state == me.capcom.smsgateway.domain.ProcessingState.Processed } -> me.capcom.smsgateway.domain.ProcessingState.Processed
|
||||
|
||||
recipients.all { it.state == me.capcom.smsgateway.domain.ProcessingState.Failed } -> me.capcom.smsgateway.domain.ProcessingState.Failed
|
||||
recipients.all { it.state == me.capcom.smsgateway.domain.ProcessingState.Delivered } -> me.capcom.smsgateway.domain.ProcessingState.Delivered
|
||||
else -> me.capcom.smsgateway.domain.ProcessingState.Sent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
data class MessagesStats(
|
||||
val count: Int,
|
||||
val lastTimestamp: Long
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
data class MessagesTotals(
|
||||
val total: Long,
|
||||
val pending: Long,
|
||||
val sent: Long,
|
||||
val delivered: Long,
|
||||
val failed: Long,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["messageId", "phoneNumber", "state"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MessageRecipient::class,
|
||||
parentColumns = ["messageId", "phoneNumber"],
|
||||
childColumns = ["messageId", "phoneNumber"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class RecipientState(
|
||||
val messageId: String,
|
||||
val phoneNumber: String,
|
||||
val state: me.capcom.smsgateway.domain.ProcessingState,
|
||||
val updatedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package me.capcom.smsgateway.data.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "tokens",
|
||||
indices = [
|
||||
Index("expiresAt")
|
||||
],
|
||||
)
|
||||
data class Token(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val expiresAt: Long,
|
||||
val revokedAt: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.domain
|
||||
|
||||
enum class EntitySource {
|
||||
Local,
|
||||
Cloud,
|
||||
|
||||
@Deprecated("Not used anymore")
|
||||
Gateway,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package me.capcom.smsgateway.domain
|
||||
|
||||
import me.capcom.smsgateway.BuildConfig
|
||||
import me.capcom.smsgateway.modules.health.domain.CheckResult
|
||||
import me.capcom.smsgateway.modules.health.domain.HealthResult
|
||||
import me.capcom.smsgateway.modules.health.domain.Status
|
||||
|
||||
class HealthResponse(
|
||||
healthResult: HealthResult,
|
||||
|
||||
val version: String = BuildConfig.VERSION_NAME,
|
||||
val releaseId: Int = BuildConfig.VERSION_CODE,
|
||||
) {
|
||||
val status: Status = healthResult.status
|
||||
val checks: Map<String, CheckResult> = healthResult.checks
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.capcom.smsgateway.domain
|
||||
|
||||
sealed class MessageContent {
|
||||
data class Text(val text: String) : MessageContent() {
|
||||
override fun toString(): String {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
data class Data(val data: String, val port: UShort) : MessageContent() {
|
||||
override fun toString(): String {
|
||||
return "$data:$port"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.domain
|
||||
|
||||
enum class ProcessingState {
|
||||
Pending,
|
||||
Processed,
|
||||
Sent,
|
||||
Delivered,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package me.capcom.smsgateway.extensions
|
||||
|
||||
import android.os.Build
|
||||
import com.google.gson.GsonBuilder
|
||||
import java.util.TimeZone
|
||||
|
||||
fun GsonBuilder.configure(): GsonBuilder {
|
||||
return this.setDateFormatISO8601()
|
||||
}
|
||||
|
||||
private fun GsonBuilder.setDateFormatISO8601(): GsonBuilder {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
this.setDateFormat(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
|
||||
)
|
||||
} else {
|
||||
//get device timezone
|
||||
val timeZone = TimeZone.getDefault()
|
||||
this.setDateFormat(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS" + when (timeZone.rawOffset) {
|
||||
0 -> "Z"
|
||||
else -> "+" + (timeZone.rawOffset / 3600000).toString().padStart(
|
||||
2,
|
||||
'0'
|
||||
) + ":" + ((timeZone.rawOffset % 3600000) / 60000).toString()
|
||||
.padStart(2, '0')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import me.capcom.smsgateway.BuildConfig
|
||||
|
||||
object BuildHelper {
|
||||
val isInsecureVersion =
|
||||
BuildConfig.BUILD_TYPE == "insecure" || BuildConfig.BUILD_TYPE == "debugInsecure"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import android.os.Build
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
object DateTimeParser {
|
||||
fun parseIsoDateTime(input: String): Date? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
parseModern(input)
|
||||
} else {
|
||||
parseLegacy(input)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NewApi")
|
||||
private fun parseModern(input: String): Date? {
|
||||
return try {
|
||||
// Pattern handles both with/without milliseconds
|
||||
val formatter = java.time.format.DateTimeFormatter.ofPattern(
|
||||
"yyyy-MM-dd'T'HH:mm:ss[.SSS]XXX"
|
||||
)
|
||||
val offsetDateTime = java.time.OffsetDateTime.parse(input, formatter)
|
||||
Date.from(offsetDateTime.toInstant())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLegacy(input: String): Date? {
|
||||
// Try patterns in order of specificity
|
||||
val patterns = arrayOf(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX", // With milliseconds
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXX" // Without milliseconds
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
try {
|
||||
val sdf = SimpleDateFormat(pattern, Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
return sdf.parse(input)
|
||||
} catch (e: ParseException) {
|
||||
// Try next pattern
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
|
||||
object PhoneHelper {
|
||||
fun filterPhoneNumber(phoneNumber: String, countryCode: String): String {
|
||||
val phoneUtil = PhoneNumberUtil.getInstance()
|
||||
val number = phoneUtil.parse(phoneNumber, countryCode.uppercase())
|
||||
if (!phoneUtil.isValidNumber(number)) {
|
||||
throw RuntimeException("Invalid phone number")
|
||||
}
|
||||
if (phoneUtil.getNumberType(number) != PhoneNumberUtil.PhoneNumberType.MOBILE
|
||||
&& phoneUtil.getNumberType(number) != PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE
|
||||
) {
|
||||
throw RuntimeException("Invalid phone number type")
|
||||
}
|
||||
return phoneUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class SSEManager(
|
||||
private val url: String,
|
||||
private val authToken: String
|
||||
) {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + Job())
|
||||
|
||||
private var eventSource: EventSource? = null
|
||||
private var reconnectAttempts = 0
|
||||
private val isDisconnecting = AtomicBoolean(false)
|
||||
|
||||
// Event callbacks
|
||||
var onEvent: ((type: String?, data: String) -> Unit)? = null
|
||||
var onConnected: (() -> Unit)? = null
|
||||
var onError: ((Throwable?) -> Unit)? = null
|
||||
var onClosed: (() -> Unit)? = null
|
||||
|
||||
fun connect() {
|
||||
isDisconnecting.set(false)
|
||||
scope.launch {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.apply {
|
||||
header("Authorization", "Bearer $authToken")
|
||||
}
|
||||
.build()
|
||||
|
||||
eventSource = EventSources.createFactory(client)
|
||||
.newEventSource(request, object : EventSourceListener() {
|
||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||
Log.d(TAG, "SSE connected")
|
||||
reconnectAttempts = 0
|
||||
onConnected?.invoke()
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
Log.d(TAG, "Event received: $type - $data")
|
||||
onEvent?.invoke(type, data)
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
Log.d(TAG, "SSE connection closed")
|
||||
onClosed?.invoke()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
Log.e(TAG, "SSE error", t)
|
||||
onError?.invoke(t)
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection failed", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
isDisconnecting.set(true)
|
||||
scope.launch {
|
||||
eventSource?.cancel()
|
||||
eventSource = null
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
scope.coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (isDisconnecting.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempts++
|
||||
val delay = when {
|
||||
reconnectAttempts > 10 -> 60_000L // 1 minute
|
||||
reconnectAttempts > 5 -> 30_000L // 30 seconds
|
||||
else -> 5_000L // 5 seconds
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
eventSource?.cancel()
|
||||
eventSource = null
|
||||
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $reconnectAttempts)")
|
||||
kotlinx.coroutines.delay(delay)
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "SSEManager"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import me.capcom.smsgateway.receivers.BootReceiver
|
||||
|
||||
class SettingsHelper(private val context: Context) {
|
||||
private val settings = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
init {
|
||||
migrate()
|
||||
}
|
||||
|
||||
var autostart: Boolean
|
||||
get() = settings.getBoolean(PREF_KEY_AUTOSTART, false)
|
||||
set(value) {
|
||||
// enable broadcast receiver
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
ComponentName(context, BootReceiver::class.java),
|
||||
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
settings.edit { putBoolean(PREF_KEY_AUTOSTART, value) }
|
||||
}
|
||||
|
||||
private fun migrate() {
|
||||
// remove after 2025-11-28
|
||||
val PREF_KEY_SERVER_TOKEN = "server_token"
|
||||
if (settings.contains(PREF_KEY_SERVER_TOKEN)) {
|
||||
settings.edit(true) {
|
||||
putString("localserver.PASSWORD", settings.getString(PREF_KEY_SERVER_TOKEN, null))
|
||||
remove(PREF_KEY_SERVER_TOKEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_KEY_AUTOSTART = "autostart"
|
||||
|
||||
private const val PREF_KEY_FCM_TOKEN = "fcm_token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package me.capcom.smsgateway.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.telephony.SubscriptionManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
object SubscriptionsHelper {
|
||||
@Suppress("DEPRECATION")
|
||||
fun getSubscriptionsManager(context: Context): SubscriptionManager? = when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1 -> null
|
||||
Build.VERSION.SDK_INT < 31 -> SubscriptionManager.from(context)
|
||||
else -> context.getSystemService(SubscriptionManager::class.java)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun selectAvailableSimSlots(context: Context): Set<Int>? {
|
||||
if (!hasPhoneStatePermission(context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val subscriptionManager = getSubscriptionsManager(context) ?: return null
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 -> subscriptionManager.activeSubscriptionInfoList.map { it.simSlotIndex }
|
||||
.toSet()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun getSubscriptionId(context: Context, simSlotIndex: Int): Int? {
|
||||
if (!hasPhoneStatePermission(context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val subscriptionManager = getSubscriptionsManager(context) ?: return null
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
subscriptionManager.activeSubscriptionInfoList.find {
|
||||
it.simSlotIndex == simSlotIndex
|
||||
}?.subscriptionId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun getSimSlotIndex(context: Context, subscriptionId: Int): Int? {
|
||||
if (!hasPhoneStatePermission(context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val subscriptionManager = getSubscriptionsManager(context) ?: return null
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
subscriptionManager.activeSubscriptionInfoList.find {
|
||||
it.subscriptionId == subscriptionId
|
||||
}?.simSlotIndex
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPhoneStatePermission(context: Context): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
fun extractSubscriptionId(context: Context, intent: Intent): Int? {
|
||||
return when {
|
||||
intent.extras?.containsKey(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX) == true -> intent.extras?.getInt(
|
||||
SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
|
||||
)
|
||||
|
||||
intent.extras?.containsKey("subscription") == true -> intent.extras?.getInt("subscription")
|
||||
intent.extras?.containsKey(SubscriptionManager.EXTRA_SLOT_INDEX) == true -> intent.extras?.getInt(
|
||||
SubscriptionManager.EXTRA_SLOT_INDEX
|
||||
)?.let { getSubscriptionId(context, it) }
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun getPhoneNumber(context: Context, simSlotIndex: Int): String? {
|
||||
if (!hasPhoneStatePermission(context)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val subscriptionManager = getSubscriptionsManager(context) ?: return null
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
subscriptionManager.activeSubscriptionInfoList?.find {
|
||||
it.simSlotIndex == simSlotIndex
|
||||
}?.number?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.connection
|
||||
|
||||
enum class CellularNetworkType {
|
||||
None,
|
||||
Unknown,
|
||||
Mobile2G,
|
||||
Mobile3G,
|
||||
Mobile4G,
|
||||
Mobile5G,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package me.capcom.smsgateway.modules.connection
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.telephony.TelephonyManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import me.capcom.smsgateway.modules.health.domain.CheckResult
|
||||
import me.capcom.smsgateway.modules.health.domain.Status
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class ConnectionService(
|
||||
private val context: Context
|
||||
) : KoinComponent {
|
||||
private val _status = MutableLiveData(false)
|
||||
val status: LiveData<Boolean> = _status
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val logsService by inject<LogsService>()
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onLost(network: Network) {
|
||||
if (_status.value == false) return
|
||||
|
||||
logsService.insert(
|
||||
LogEntry.Priority.WARN,
|
||||
MODULE_NAME,
|
||||
"Internet connection lost"
|
||||
)
|
||||
|
||||
_status.postValue(false)
|
||||
|
||||
super.onLost(network)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val hasInternet =
|
||||
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
&& (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || networkCapabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
))
|
||||
|
||||
if (_status.value == hasInternet) return
|
||||
|
||||
logsService.insert(
|
||||
LogEntry.Priority.INFO,
|
||||
MODULE_NAME,
|
||||
"Internet connection status: $hasInternet"
|
||||
)
|
||||
|
||||
_status.postValue(hasInternet)
|
||||
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
fun healthCheck(): Map<String, CheckResult> {
|
||||
val status = when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
true -> when (_status.value) {
|
||||
true -> Status.PASS
|
||||
else -> Status.FAIL
|
||||
}
|
||||
|
||||
false -> Status.PASS
|
||||
}
|
||||
val transport = transportType
|
||||
val cellularType = cellularNetworkType
|
||||
|
||||
return mapOf(
|
||||
"status" to CheckResult(
|
||||
status,
|
||||
when (status) {
|
||||
Status.PASS -> 1L
|
||||
else -> 0L
|
||||
},
|
||||
"boolean",
|
||||
"Internet connection status"
|
||||
),
|
||||
"transport" to CheckResult(
|
||||
when (transport.isEmpty()) {
|
||||
true -> Status.FAIL
|
||||
false -> Status.PASS
|
||||
},
|
||||
transport.sumOf { it.value }.toLong(),
|
||||
"flags",
|
||||
"Network transport type"
|
||||
),
|
||||
"cellular" to CheckResult(
|
||||
Status.PASS,
|
||||
cellularType.ordinal.toLong(),
|
||||
"index",
|
||||
"Cellular network type"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val transportType: Set<TransportType>
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return setOf(TransportType.Unknown)
|
||||
|
||||
val result = mutableSetOf<TransportType>()
|
||||
|
||||
val nw = connectivityManager.activeNetwork ?: return result
|
||||
val actNw = connectivityManager.getNetworkCapabilities(nw) ?: return result
|
||||
|
||||
if (actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
result.add(TransportType.WiFi)
|
||||
}
|
||||
if (actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
result.add(TransportType.Ethernet)
|
||||
}
|
||||
if (actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
result.add(TransportType.Cellular)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
val cellularNetworkType: CellularNetworkType
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return CellularNetworkType.Unknown
|
||||
|
||||
val transport = transportType
|
||||
|
||||
if (transport.contains(TransportType.Unknown)) {
|
||||
return CellularNetworkType.Unknown
|
||||
}
|
||||
if (!transport.contains(TransportType.Cellular)) {
|
||||
return CellularNetworkType.None
|
||||
}
|
||||
|
||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return CellularNetworkType.Unknown
|
||||
}
|
||||
when (tm.dataNetworkType) {
|
||||
TelephonyManager.NETWORK_TYPE_GPRS,
|
||||
TelephonyManager.NETWORK_TYPE_EDGE,
|
||||
TelephonyManager.NETWORK_TYPE_CDMA,
|
||||
TelephonyManager.NETWORK_TYPE_1xRTT,
|
||||
TelephonyManager.NETWORK_TYPE_IDEN,
|
||||
TelephonyManager.NETWORK_TYPE_GSM -> return CellularNetworkType.Mobile2G
|
||||
|
||||
TelephonyManager.NETWORK_TYPE_UMTS,
|
||||
TelephonyManager.NETWORK_TYPE_EVDO_0,
|
||||
TelephonyManager.NETWORK_TYPE_EVDO_A,
|
||||
TelephonyManager.NETWORK_TYPE_HSDPA,
|
||||
TelephonyManager.NETWORK_TYPE_HSUPA,
|
||||
TelephonyManager.NETWORK_TYPE_HSPA,
|
||||
TelephonyManager.NETWORK_TYPE_EVDO_B,
|
||||
TelephonyManager.NETWORK_TYPE_EHRPD,
|
||||
TelephonyManager.NETWORK_TYPE_HSPAP,
|
||||
TelephonyManager.NETWORK_TYPE_TD_SCDMA -> return CellularNetworkType.Mobile3G
|
||||
|
||||
TelephonyManager.NETWORK_TYPE_LTE,
|
||||
TelephonyManager.NETWORK_TYPE_IWLAN, 19 -> return CellularNetworkType.Mobile4G
|
||||
|
||||
TelephonyManager.NETWORK_TYPE_NR -> return CellularNetworkType.Mobile5G
|
||||
}
|
||||
|
||||
return CellularNetworkType.Unknown
|
||||
}
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.connection
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val connectionModule = module {
|
||||
singleOf(::ConnectionService)
|
||||
}
|
||||
|
||||
val MODULE_NAME = "connection"
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.connection
|
||||
|
||||
enum class TransportType(
|
||||
val value: Int
|
||||
) {
|
||||
Unknown(1),
|
||||
Cellular(2),
|
||||
WiFi(4),
|
||||
Ethernet(8),
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package me.capcom.smsgateway.modules.encryption
|
||||
|
||||
import android.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class EncryptionService(
|
||||
private val settings: EncryptionSettings,
|
||||
) {
|
||||
fun decrypt(encryptedText: String): String {
|
||||
val chunks = encryptedText.split('$')
|
||||
if (chunks.size < 5)
|
||||
throw RuntimeException("Invalid encrypted data format")
|
||||
|
||||
if (chunks[1] != "aes-256-cbc/pbkdf2-sha1") {
|
||||
throw RuntimeException("Unsupported algorithm")
|
||||
}
|
||||
|
||||
val params = parseParams(chunks[2])
|
||||
if (!params.containsKey("i")) {
|
||||
throw RuntimeException("Missing iteration count")
|
||||
}
|
||||
|
||||
val salt = decode(chunks[3])
|
||||
val text = chunks[4]
|
||||
|
||||
val passphrase = requireNotNull(settings.passphrase) { "Passphrase is not set" }
|
||||
val secretKey = generateSecretKeyFromPassphrase(
|
||||
passphrase.toCharArray(),
|
||||
salt,
|
||||
256,
|
||||
params.getValue("i").toInt()
|
||||
)
|
||||
|
||||
return decryptText(text, secretKey, salt)
|
||||
}
|
||||
|
||||
private fun decryptText(encryptedText: String, secretKey: SecretKey, iv: ByteArray): String {
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||
val encryptedBytes = decode(encryptedText)
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedBytes)
|
||||
}
|
||||
|
||||
private fun decode(input: String): ByteArray {
|
||||
return Base64.decode(input, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
private fun generateSecretKeyFromPassphrase(
|
||||
passphrase: CharArray,
|
||||
salt: ByteArray,
|
||||
keyLength: Int = 256,
|
||||
iterationCount: Int = 300_000
|
||||
): SecretKey {
|
||||
val keySpec = PBEKeySpec(passphrase, salt, iterationCount, keyLength)
|
||||
val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||
val keyBytes = keyFactory.generateSecret(keySpec).encoded
|
||||
return SecretKeySpec(keyBytes, "AES")
|
||||
}
|
||||
|
||||
private fun parseParams(params: String): Map<String, String> {
|
||||
return params.split(',')
|
||||
.map { it.split('=', limit = 2) }
|
||||
.associate { it[0] to it[1] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package me.capcom.smsgateway.modules.encryption
|
||||
|
||||
import me.capcom.smsgateway.modules.settings.Importer
|
||||
import me.capcom.smsgateway.modules.settings.KeyValueStorage
|
||||
import me.capcom.smsgateway.modules.settings.get
|
||||
|
||||
class EncryptionSettings(
|
||||
private val storage: KeyValueStorage,
|
||||
) : Importer {
|
||||
val passphrase: String?
|
||||
get() = storage.get<String>(PASSPHRASE)
|
||||
|
||||
private var version: Int
|
||||
get() = storage.get<Int>(VERSION) ?: 0
|
||||
set(value) = storage.set(VERSION, value)
|
||||
|
||||
init {
|
||||
migrate()
|
||||
}
|
||||
|
||||
private fun migrate() {
|
||||
if (version == VERSION_CODE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (version < 1) {
|
||||
passphrase?.let {
|
||||
storage.set(PASSPHRASE, it)
|
||||
}
|
||||
}
|
||||
|
||||
version = VERSION_CODE
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VERSION_CODE = 1
|
||||
|
||||
private const val PASSPHRASE = "passphrase"
|
||||
|
||||
private const val VERSION = "version"
|
||||
}
|
||||
|
||||
override fun import(data: Map<String, *>): Boolean {
|
||||
return data.map {
|
||||
when (it.key) {
|
||||
PASSPHRASE -> {
|
||||
val newValue = it.value?.toString()
|
||||
val changed = passphrase != newValue
|
||||
storage.set(it.key, newValue)
|
||||
changed
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}.any { it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.modules.encryption
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val encryptionModule = module {
|
||||
single {
|
||||
EncryptionService(get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
open class AppEvent(
|
||||
@Transient
|
||||
val name: String,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
class EventBus {
|
||||
private val _events = MutableSharedFlow<AppEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
suspend fun emit(event: AppEvent) {
|
||||
Log.d("EventBus", "${Thread.currentThread().name} emitted ${event.name}")
|
||||
_events.emit(event)
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : AppEvent> collect(crossinline block: suspend (T) -> Unit) {
|
||||
events
|
||||
.filter {
|
||||
it is T
|
||||
}
|
||||
.collect { block(it as T) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
abstract class EventsReceiver : KoinComponent {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var job: Job? = null
|
||||
|
||||
private val eventBus = get<EventBus>()
|
||||
|
||||
fun start() {
|
||||
stop()
|
||||
|
||||
this.job = scope.launch {
|
||||
collect(eventBus)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun collect(eventBus: EventBus)
|
||||
|
||||
fun stop() {
|
||||
this.job?.cancel()
|
||||
this.job = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
data class ExternalEvent(
|
||||
val type: ExternalEventType,
|
||||
val data: String?,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
enum class ExternalEventType {
|
||||
@SerializedName("MessageEnqueued")
|
||||
MessageEnqueued,
|
||||
|
||||
@SerializedName("WebhooksUpdated")
|
||||
WebhooksUpdated,
|
||||
|
||||
@SerializedName("MessagesExportRequested")
|
||||
MessagesExportRequested,
|
||||
|
||||
@SerializedName("SettingsUpdated")
|
||||
SettingsUpdated,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.capcom.smsgateway.modules.events
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val eventBusModule = module {
|
||||
single { EventBus() }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package me.capcom.smsgateway.modules.gateway
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.modules.events.EventBus
|
||||
import me.capcom.smsgateway.modules.events.EventsReceiver
|
||||
import me.capcom.smsgateway.modules.gateway.events.DeviceRegisteredEvent
|
||||
import me.capcom.smsgateway.modules.gateway.events.MessageEnqueuedEvent
|
||||
import me.capcom.smsgateway.modules.gateway.events.SettingsUpdatedEvent
|
||||
import me.capcom.smsgateway.modules.gateway.events.WebhooksUpdatedEvent
|
||||
import me.capcom.smsgateway.modules.gateway.services.SSEForegroundService
|
||||
import me.capcom.smsgateway.modules.gateway.workers.PullMessagesWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.SendStateWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.SettingsUpdateWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.WebhooksUpdateWorker
|
||||
import me.capcom.smsgateway.modules.messages.events.MessageStateChangedEvent
|
||||
import me.capcom.smsgateway.modules.ping.events.PingEvent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class EventsReceiver : EventsReceiver() {
|
||||
|
||||
private val settings = get<GatewaySettings>()
|
||||
|
||||
override suspend fun collect(eventBus: EventBus) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched MessageEnqueuedEvent")
|
||||
eventBus.collect<MessageEnqueuedEvent> { event ->
|
||||
Log.d("EventsReceiver", "Event: $event")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
|
||||
PullMessagesWorker.start(get())
|
||||
}
|
||||
}
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched MessageStateChangedEvent")
|
||||
val allowedSources = setOf(EntitySource.Cloud, EntitySource.Gateway)
|
||||
eventBus.collect<MessageStateChangedEvent> { event ->
|
||||
Log.d("EventsReceiver", "Event: $event")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
|
||||
if (event.source !in allowedSources) return@collect
|
||||
|
||||
SendStateWorker.start(get(), event.id)
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched PingEvent")
|
||||
eventBus.collect<PingEvent> {
|
||||
Log.d("EventsReceiver", "Event: $it")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
|
||||
PullMessagesWorker.start(get())
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched WebhooksUpdatedEvent")
|
||||
eventBus.collect<WebhooksUpdatedEvent> {
|
||||
Log.d("EventsReceiver", "Event: $it")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
|
||||
WebhooksUpdateWorker.start(get())
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched SettingsUpdatedEvent")
|
||||
eventBus.collect<SettingsUpdatedEvent> {
|
||||
Log.d("EventsReceiver", "Event: $it")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
|
||||
SettingsUpdateWorker.start(get())
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
Log.d("EventsReceiver", "launched DeviceRegisteredEvent")
|
||||
eventBus.collect<DeviceRegisteredEvent> {
|
||||
Log.d("EventsReceiver", "Event: $it")
|
||||
|
||||
if (!settings.enabled) return@collect
|
||||
if (settings.fcmToken != null) return@collect
|
||||
|
||||
SSEForegroundService.start(get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package me.capcom.smsgateway.modules.gateway
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.hostWithPort
|
||||
import io.ktor.serialization.gson.gson
|
||||
import io.ktor.util.encodeBase64
|
||||
import me.capcom.smsgateway.BuildConfig
|
||||
import me.capcom.smsgateway.domain.ProcessingState
|
||||
import me.capcom.smsgateway.extensions.configure
|
||||
import me.capcom.smsgateway.modules.webhooks.domain.WebHookEvent
|
||||
import java.util.Date
|
||||
|
||||
class GatewayApi(
|
||||
private val baseUrl: String,
|
||||
private val privateToken: String?
|
||||
) {
|
||||
val hostname: String
|
||||
get() = Url(baseUrl).hostWithPort
|
||||
|
||||
private val client = HttpClient(OkHttp) {
|
||||
install(UserAgent) {
|
||||
agent = "me.capcom.smsgateway/" + BuildConfig.VERSION_NAME
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
gson {
|
||||
configure()
|
||||
}
|
||||
}
|
||||
expectSuccess = true
|
||||
}
|
||||
|
||||
suspend fun getDevice(token: String?): DeviceGetResponse {
|
||||
return client.get("$baseUrl/device") {
|
||||
token?.let { bearerAuth(it) }
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun deviceRegister(
|
||||
request: DeviceRegisterRequest,
|
||||
credentials: Pair<String, String>?
|
||||
): DeviceRegisterResponse {
|
||||
return client.post("$baseUrl/device") {
|
||||
when {
|
||||
credentials != null -> basicAuth(credentials.first, credentials.second)
|
||||
privateToken != null -> bearerAuth(privateToken)
|
||||
}
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun deviceRegister(
|
||||
request: DeviceRegisterRequest,
|
||||
code: String
|
||||
): DeviceRegisterResponse {
|
||||
return client.post("$baseUrl/device") {
|
||||
header("Authorization", "Code $code")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun devicePatch(token: String, request: DevicePatchRequest) {
|
||||
client.patch("$baseUrl/device") {
|
||||
bearerAuth(token)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMessages(token: String, processingOrder: ProcessingOrder): List<Message> {
|
||||
return client.get("$baseUrl/message") {
|
||||
parameter("order", processingOrder)
|
||||
bearerAuth(token)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun patchMessages(token: String, request: List<MessagePatchRequest>) {
|
||||
client.patch("$baseUrl/message") {
|
||||
bearerAuth(token)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getWebHooks(token: String): List<WebHook> {
|
||||
return client.get("$baseUrl/webhooks") {
|
||||
bearerAuth(token)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun getUserCode(credentials: Pair<String, String>): GetUserCodeResponse {
|
||||
return client.get("$baseUrl/user/code") {
|
||||
basicAuth(credentials.first, credentials.second)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun changeUserPassword(token: String, request: PasswordChangeRequest) {
|
||||
client.patch("$baseUrl/user/password") {
|
||||
bearerAuth(token)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSettings(token: String): Map<String, *> {
|
||||
return client.get("$baseUrl/settings") {
|
||||
bearerAuth(token)
|
||||
}.body()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
private fun HttpRequestBuilder.bearerAuth(token: String) {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
|
||||
private fun HttpRequestBuilder.basicAuth(username: String, password: String) {
|
||||
header(HttpHeaders.Authorization, "Basic ${"$username:$password".encodeBase64()}")
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
data class DeviceGetResponse(
|
||||
val externalIp: String,
|
||||
)
|
||||
|
||||
data class DeviceRegisterRequest(
|
||||
val name: String,
|
||||
val pushToken: String?,
|
||||
)
|
||||
|
||||
data class DeviceRegisterResponse(
|
||||
val id: String,
|
||||
val token: String,
|
||||
val login: String,
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
data class DevicePatchRequest(
|
||||
val id: String,
|
||||
val pushToken: String?,
|
||||
)
|
||||
|
||||
data class MessagePatchRequest(
|
||||
val id: String,
|
||||
val state: ProcessingState,
|
||||
val recipients: List<RecipientState>,
|
||||
val states: Map<ProcessingState, Date>
|
||||
)
|
||||
|
||||
data class PasswordChangeRequest(
|
||||
val currentPassword: String,
|
||||
val newPassword: String
|
||||
)
|
||||
|
||||
data class GetUserCodeResponse(
|
||||
val code: String,
|
||||
val validUntil: Date
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
sealed class MessageContent {
|
||||
class Text(
|
||||
val text: String,
|
||||
) : MessageContent()
|
||||
|
||||
class Data(
|
||||
val data: String,
|
||||
val port: UShort,
|
||||
) : MessageContent()
|
||||
}
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
@SerializedName("textMessage")
|
||||
val _textMessage: MessageContent.Text?,
|
||||
@SerializedName("dataMessage")
|
||||
val _dataMessage: MessageContent.Data?,
|
||||
val phoneNumbers: List<String>,
|
||||
val simNumber: Int?,
|
||||
val withDeliveryReport: Boolean?,
|
||||
val isEncrypted: Boolean?,
|
||||
val validUntil: Date?,
|
||||
val priority: Byte?,
|
||||
val createdAt: Date?,
|
||||
|
||||
@SerializedName("message")
|
||||
val _message: String?,
|
||||
) {
|
||||
val content: MessageContent
|
||||
get() = this._dataMessage
|
||||
?: this._textMessage
|
||||
?: _message?.let { MessageContent.Text(it) }
|
||||
?: throw RuntimeException("Invalid message content")
|
||||
}
|
||||
|
||||
data class RecipientState(
|
||||
val phoneNumber: String,
|
||||
val state: ProcessingState,
|
||||
val error: String?,
|
||||
)
|
||||
|
||||
data class WebHook(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val event: WebHookEvent,
|
||||
)
|
||||
|
||||
enum class ProcessingOrder {
|
||||
@SerializedName("lifo")
|
||||
LIFO,
|
||||
|
||||
@SerializedName("fifo")
|
||||
FIFO;
|
||||
|
||||
override fun toString(): String {
|
||||
return this.name.lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package me.capcom.smsgateway.modules.gateway
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import me.capcom.smsgateway.data.entities.MessageWithRecipients
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.domain.MessageContent
|
||||
import me.capcom.smsgateway.modules.events.EventBus
|
||||
import me.capcom.smsgateway.modules.gateway.events.DeviceRegisteredEvent
|
||||
import me.capcom.smsgateway.modules.gateway.services.SSEForegroundService
|
||||
import me.capcom.smsgateway.modules.gateway.workers.PullMessagesWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.SendStateWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.SettingsUpdateWorker
|
||||
import me.capcom.smsgateway.modules.gateway.workers.WebhooksUpdateWorker
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import me.capcom.smsgateway.modules.messages.MessagesService
|
||||
import me.capcom.smsgateway.modules.messages.MessagesSettings
|
||||
import me.capcom.smsgateway.modules.messages.data.SendParams
|
||||
import me.capcom.smsgateway.modules.messages.data.SendRequest
|
||||
import me.capcom.smsgateway.services.PushService
|
||||
import java.util.Date
|
||||
|
||||
class GatewayService(
|
||||
private val messagesService: MessagesService,
|
||||
private val settings: GatewaySettings,
|
||||
private val events: EventBus,
|
||||
private val logsService: LogsService,
|
||||
) {
|
||||
private val eventsReceiver by lazy { EventsReceiver() }
|
||||
|
||||
private var _api: GatewayApi? = null
|
||||
|
||||
private val api
|
||||
get() = _api ?: GatewayApi(
|
||||
settings.serverUrl,
|
||||
settings.privateToken
|
||||
).also { _api = it }
|
||||
|
||||
//region Start, stop, etc...
|
||||
fun start(context: Context) {
|
||||
if (!settings.enabled) return
|
||||
|
||||
PushService.register(context)
|
||||
PullMessagesWorker.start(context)
|
||||
WebhooksUpdateWorker.start(context)
|
||||
SettingsUpdateWorker.start(context)
|
||||
|
||||
eventsReceiver.start()
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
eventsReceiver.stop()
|
||||
|
||||
SSEForegroundService.stop(context)
|
||||
SettingsUpdateWorker.stop(context)
|
||||
WebhooksUpdateWorker.stop(context)
|
||||
PullMessagesWorker.stop(context)
|
||||
|
||||
this._api = null
|
||||
}
|
||||
|
||||
fun isActiveLiveData(context: Context) = PullMessagesWorker.getStateLiveData(context)
|
||||
//endregion
|
||||
|
||||
//region Account
|
||||
suspend fun getLoginCode(): GatewayApi.GetUserCodeResponse {
|
||||
val username = settings.username
|
||||
?: throw IllegalStateException("Username is not set")
|
||||
val password = settings.password
|
||||
?: throw IllegalStateException("Password is not set")
|
||||
|
||||
return api.getUserCode(username to password)
|
||||
}
|
||||
|
||||
suspend fun changePassword(current: String, new: String) {
|
||||
val info = settings.registrationInfo
|
||||
?: throw IllegalStateException("The device is not registered on the server")
|
||||
|
||||
this.api.changeUserPassword(
|
||||
info.token,
|
||||
GatewayApi.PasswordChangeRequest(current, new)
|
||||
)
|
||||
|
||||
settings.registrationInfo = info.copy(password = new)
|
||||
|
||||
events.emit(
|
||||
DeviceRegisteredEvent.Success(
|
||||
api.hostname,
|
||||
info.login,
|
||||
new,
|
||||
)
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Device
|
||||
internal suspend fun registerDevice(
|
||||
pushToken: String?,
|
||||
registerMode: RegistrationMode
|
||||
) {
|
||||
if (!settings.enabled) return
|
||||
|
||||
val settings = settings.registrationInfo
|
||||
val accessToken = settings?.token
|
||||
|
||||
if (accessToken != null) {
|
||||
// if there's an access token, try to update push token
|
||||
try {
|
||||
updateDevice(pushToken)
|
||||
return
|
||||
} catch (e: ClientRequestException) {
|
||||
// if token is invalid, try to register new one
|
||||
if (e.response.status != HttpStatusCode.Unauthorized) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val deviceName = "${Build.MANUFACTURER}/${Build.PRODUCT}"
|
||||
val request = GatewayApi.DeviceRegisterRequest(
|
||||
deviceName,
|
||||
pushToken
|
||||
)
|
||||
val response = when (registerMode) {
|
||||
RegistrationMode.Anonymous -> api.deviceRegister(request, null)
|
||||
is RegistrationMode.WithCode -> api.deviceRegister(request, registerMode.code)
|
||||
is RegistrationMode.WithCredentials -> api.deviceRegister(
|
||||
request,
|
||||
registerMode.login to registerMode.password
|
||||
)
|
||||
}
|
||||
|
||||
this.settings.fcmToken = pushToken
|
||||
this.settings.registrationInfo = response
|
||||
|
||||
events.emit(
|
||||
DeviceRegisteredEvent.Success(
|
||||
api.hostname,
|
||||
response.login,
|
||||
response.password,
|
||||
)
|
||||
)
|
||||
} catch (th: Throwable) {
|
||||
events.emit(
|
||||
DeviceRegisteredEvent.Failure(
|
||||
api.hostname,
|
||||
th.localizedMessage ?: th.message ?: th.toString()
|
||||
)
|
||||
)
|
||||
|
||||
throw th
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun updateDevice(pushToken: String?) {
|
||||
if (!settings.enabled) return
|
||||
|
||||
val settings = settings.registrationInfo ?: return
|
||||
val accessToken = settings.token
|
||||
|
||||
api.devicePatch(
|
||||
accessToken,
|
||||
GatewayApi.DevicePatchRequest(
|
||||
settings.id,
|
||||
pushToken
|
||||
)
|
||||
)
|
||||
|
||||
this.settings.fcmToken = pushToken
|
||||
|
||||
events.emit(
|
||||
DeviceRegisteredEvent.Success(
|
||||
api.hostname,
|
||||
settings.login,
|
||||
settings.password,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
sealed class RegistrationMode {
|
||||
object Anonymous : RegistrationMode()
|
||||
class WithCredentials(val login: String, val password: String) : RegistrationMode()
|
||||
class WithCode(val code: String) : RegistrationMode()
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Messages
|
||||
internal suspend fun getNewMessages(context: Context) {
|
||||
if (!settings.enabled) return
|
||||
val settings = settings.registrationInfo ?: return
|
||||
val processingOrder = when (messagesService.processingOrder) {
|
||||
MessagesSettings.ProcessingOrder.LIFO -> GatewayApi.ProcessingOrder.LIFO
|
||||
MessagesSettings.ProcessingOrder.FIFO -> GatewayApi.ProcessingOrder.FIFO
|
||||
}
|
||||
val messages = api.getMessages(settings.token, processingOrder)
|
||||
for (message in messages) {
|
||||
try {
|
||||
processMessage(context, message)
|
||||
} catch (th: Throwable) {
|
||||
logsService.insert(
|
||||
LogEntry.Priority.ERROR,
|
||||
MODULE_NAME,
|
||||
"Failed to process message",
|
||||
mapOf(
|
||||
"message" to message,
|
||||
"exception" to th.stackTraceToString(),
|
||||
)
|
||||
)
|
||||
th.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessage(context: Context, message: GatewayApi.Message) {
|
||||
val messageState = messagesService.getMessage(message.id)
|
||||
if (messageState != null) {
|
||||
SendStateWorker.start(context, message.id)
|
||||
return
|
||||
}
|
||||
|
||||
val request = SendRequest(
|
||||
EntitySource.Cloud,
|
||||
me.capcom.smsgateway.modules.messages.data.Message(
|
||||
message.id,
|
||||
when (val content = message.content) {
|
||||
is GatewayApi.MessageContent.Text -> MessageContent.Text(content.text)
|
||||
is GatewayApi.MessageContent.Data -> MessageContent.Data(
|
||||
content.data,
|
||||
content.port
|
||||
)
|
||||
},
|
||||
message.phoneNumbers,
|
||||
message.isEncrypted ?: false,
|
||||
message.createdAt ?: Date(),
|
||||
),
|
||||
SendParams(
|
||||
message.withDeliveryReport ?: true,
|
||||
skipPhoneValidation = true,
|
||||
simNumber = message.simNumber,
|
||||
validUntil = message.validUntil,
|
||||
priority = message.priority,
|
||||
)
|
||||
)
|
||||
messagesService.enqueueMessage(request)
|
||||
}
|
||||
|
||||
internal suspend fun sendState(
|
||||
message: MessageWithRecipients
|
||||
) {
|
||||
val settings = settings.registrationInfo ?: return
|
||||
|
||||
api.patchMessages(
|
||||
settings.token,
|
||||
listOf(
|
||||
GatewayApi.MessagePatchRequest(
|
||||
message.message.id,
|
||||
message.message.state,
|
||||
message.recipients.map {
|
||||
GatewayApi.RecipientState(
|
||||
it.phoneNumber,
|
||||
it.state,
|
||||
it.error
|
||||
)
|
||||
},
|
||||
message.states.associate { it.state to Date(it.updatedAt) }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Webhooks
|
||||
internal suspend fun getWebHooks(): List<GatewayApi.WebHook> {
|
||||
val settings = settings.registrationInfo
|
||||
return if (settings != null) {
|
||||
api.getWebHooks(settings.token)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Settings
|
||||
internal suspend fun getSettings(): Map<String, *>? {
|
||||
val settings = settings.registrationInfo ?: return null
|
||||
|
||||
return api.getSettings(settings.token)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Utility
|
||||
suspend fun getPublicIP(): String {
|
||||
return GatewayApi(
|
||||
settings.serverUrl,
|
||||
settings.privateToken
|
||||
)
|
||||
.getDevice(settings.registrationInfo?.token)
|
||||
.externalIp
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package me.capcom.smsgateway.modules.gateway
|
||||
|
||||
import me.capcom.smsgateway.modules.settings.Exporter
|
||||
import me.capcom.smsgateway.modules.settings.Importer
|
||||
import me.capcom.smsgateway.modules.settings.KeyValueStorage
|
||||
import me.capcom.smsgateway.modules.settings.get
|
||||
|
||||
class GatewaySettings(
|
||||
private val storage: KeyValueStorage,
|
||||
) : Exporter, Importer {
|
||||
enum class NotificationChannel {
|
||||
AUTO,
|
||||
SSE_ONLY,
|
||||
}
|
||||
|
||||
var enabled: Boolean
|
||||
get() = storage.get<Boolean>(ENABLED) ?: false
|
||||
set(value) = storage.set(ENABLED, value)
|
||||
|
||||
val deviceId: String?
|
||||
get() = registrationInfo?.id
|
||||
|
||||
var registrationInfo: GatewayApi.DeviceRegisterResponse?
|
||||
get() = storage.get(REGISTRATION_INFO)
|
||||
set(value) = storage.set(REGISTRATION_INFO, value)
|
||||
|
||||
var fcmToken: String?
|
||||
get() = storage.get(FCM_TOKEN)
|
||||
set(value) = storage.set(FCM_TOKEN, value)
|
||||
|
||||
val username: String?
|
||||
get() = registrationInfo?.login
|
||||
val password: String?
|
||||
get() = registrationInfo?.password
|
||||
|
||||
val serverUrl: String
|
||||
get() = storage.get<String?>(CLOUD_URL) ?: PUBLIC_URL
|
||||
val privateToken: String?
|
||||
get() = storage.get<String>(PRIVATE_TOKEN)
|
||||
|
||||
val notificationChannel: NotificationChannel
|
||||
get() = storage.get<NotificationChannel>(NOTIFICATION_CHANNEL) ?: NotificationChannel.AUTO
|
||||
|
||||
companion object {
|
||||
private const val REGISTRATION_INFO = "REGISTRATION_INFO"
|
||||
private const val ENABLED = "ENABLED"
|
||||
private const val FCM_TOKEN = "fcm_token"
|
||||
|
||||
private const val CLOUD_URL = "cloud_url"
|
||||
private const val PRIVATE_TOKEN = "private_token"
|
||||
private const val NOTIFICATION_CHANNEL = "notification_channel"
|
||||
|
||||
const val PUBLIC_URL = "https://api.sms-gate.app/mobile/v1"
|
||||
}
|
||||
|
||||
override fun export(): Map<String, *> {
|
||||
return mapOf(
|
||||
CLOUD_URL to serverUrl,
|
||||
NOTIFICATION_CHANNEL to notificationChannel.name,
|
||||
)
|
||||
}
|
||||
|
||||
override fun import(data: Map<String, *>): Boolean {
|
||||
return data.map {
|
||||
when (it.key) {
|
||||
CLOUD_URL -> {
|
||||
val url = it.value?.toString() ?: PUBLIC_URL
|
||||
if (url != null && !url.startsWith("https://")) {
|
||||
throw IllegalArgumentException("url must start with https://")
|
||||
}
|
||||
|
||||
val changed = serverUrl != url
|
||||
|
||||
storage.set(it.key, url)
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
PRIVATE_TOKEN -> {
|
||||
val newValue = it.value?.toString()
|
||||
val changed = privateToken != newValue
|
||||
|
||||
storage.set(it.key, newValue)
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
NOTIFICATION_CHANNEL -> {
|
||||
val newValue = it.value?.let { NotificationChannel.valueOf(it.toString()) }
|
||||
?: NotificationChannel.AUTO
|
||||
val changed = notificationChannel != newValue
|
||||
|
||||
storage.set(it.key, newValue.name)
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}.any { it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.gateway
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val gatewayModule = module {
|
||||
singleOf(::GatewayService)
|
||||
}
|
||||
|
||||
val MODULE_NAME = "gateway"
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package me.capcom.smsgateway.modules.gateway.events
|
||||
|
||||
import me.capcom.smsgateway.modules.events.AppEvent
|
||||
|
||||
sealed class DeviceRegisteredEvent(
|
||||
val server: String,
|
||||
) : AppEvent(NAME) {
|
||||
class Success(
|
||||
server: String,
|
||||
val login: String,
|
||||
val password: String?,
|
||||
) : DeviceRegisteredEvent(server)
|
||||
|
||||
class Failure(
|
||||
server: String,
|
||||
val reason: String,
|
||||
) : DeviceRegisteredEvent(server)
|
||||
|
||||
companion object {
|
||||
const val NAME = "DeviceRegisteredEvent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.modules.gateway.events
|
||||
|
||||
import me.capcom.smsgateway.modules.events.AppEvent
|
||||
|
||||
class MessageEnqueuedEvent : AppEvent(NAME) {
|
||||
companion object {
|
||||
const val NAME = "MessageEnqueuedEvent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.gateway.events
|
||||
|
||||
import me.capcom.smsgateway.modules.events.AppEvent
|
||||
|
||||
class SettingsUpdatedEvent : AppEvent(NAME) {
|
||||
|
||||
companion object {
|
||||
const val NAME = "SettingsUpdatedEvent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package me.capcom.smsgateway.modules.gateway.events
|
||||
|
||||
import me.capcom.smsgateway.modules.events.AppEvent
|
||||
|
||||
class WebhooksUpdatedEvent : AppEvent(NAME) {
|
||||
companion object {
|
||||
const val NAME = "WebhooksUpdatedEvent"
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package me.capcom.smsgateway.modules.gateway.services
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import me.capcom.smsgateway.R
|
||||
import me.capcom.smsgateway.helpers.SSEManager
|
||||
import me.capcom.smsgateway.modules.events.ExternalEvent
|
||||
import me.capcom.smsgateway.modules.events.ExternalEventType
|
||||
import me.capcom.smsgateway.modules.gateway.GatewaySettings
|
||||
import me.capcom.smsgateway.modules.gateway.workers.PullMessagesWorker
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import me.capcom.smsgateway.modules.notifications.NotificationsService
|
||||
import me.capcom.smsgateway.modules.orchestrator.EventsRouter
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class SSEForegroundService : Service() {
|
||||
private val settings: GatewaySettings by inject()
|
||||
|
||||
private val eventsRouter by inject<EventsRouter>()
|
||||
|
||||
private val notificationsSvc: NotificationsService by inject()
|
||||
private val logsService: LogsService by inject()
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.javaClass.name)
|
||||
}
|
||||
}
|
||||
private val wifiLock: WifiManager.WifiLock by lazy {
|
||||
(getSystemService(Context.WIFI_SERVICE) as WifiManager).createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
this.javaClass.name
|
||||
)
|
||||
}
|
||||
|
||||
private val sseManager by lazy {
|
||||
SSEManager(
|
||||
"${settings.serverUrl}/events",
|
||||
requireNotNull(
|
||||
settings.registrationInfo?.token
|
||||
) { "Authentication token is required for SSE connection" }
|
||||
)
|
||||
.apply {
|
||||
onConnected = {
|
||||
Log.d("SSEForegroundService", "SSE connected, pulling pending messages")
|
||||
try {
|
||||
PullMessagesWorker.start(this@SSEForegroundService)
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
logsService.insert(
|
||||
LogEntry.Priority.ERROR,
|
||||
"SSEForegroundService",
|
||||
"Failed to start PullMessagesWorker on connect",
|
||||
)
|
||||
}
|
||||
}
|
||||
onEvent = { event, data ->
|
||||
Log.d("SSEForegroundService", "$event: $data")
|
||||
|
||||
try {
|
||||
processEvent(event, data)
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
|
||||
logsService.insert(
|
||||
LogEntry.Priority.ERROR,
|
||||
"SSEForegroundService",
|
||||
"Failed to process event",
|
||||
mapOf("event" to event, "data" to data)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (!wakeLock.isHeld) {
|
||||
wakeLock.acquire()
|
||||
}
|
||||
if (!wifiLock.isHeld) {
|
||||
wifiLock.acquire()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val notification = notificationsSvc.makeNotification(
|
||||
this,
|
||||
NotificationsService.NOTIFICATION_ID_REALTIME_EVENTS,
|
||||
getString(R.string.listening_to_the_server_events)
|
||||
)
|
||||
|
||||
startForeground(NotificationsService.NOTIFICATION_ID_REALTIME_EVENTS, notification)
|
||||
|
||||
sseManager.connect()
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun processEvent(event: String?, data: String) {
|
||||
val type = try {
|
||||
event?.let { ExternalEventType.valueOf(it) }
|
||||
?: ExternalEventType.MessageEnqueued
|
||||
} catch (e: Throwable) {
|
||||
throw RuntimeException("Unknown event type: $event", e)
|
||||
}
|
||||
|
||||
eventsRouter.route(
|
||||
ExternalEvent(
|
||||
type = type,
|
||||
data = data
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
sseManager.disconnect()
|
||||
if (wifiLock.isHeld) {
|
||||
wifiLock.release()
|
||||
}
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, SSEForegroundService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, SSEForegroundService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package me.capcom.smsgateway.modules.gateway.workers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.map
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.capcom.smsgateway.App
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PullMessagesWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
App.instance.gatewayService.getNewMessages(
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
return Result.success()
|
||||
} catch (th: Throwable) {
|
||||
th.printStackTrace()
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "PullMessagesWorker"
|
||||
|
||||
fun start(context: Context) {
|
||||
val work = PeriodicWorkRequestBuilder<PullMessagesWorker>(PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
work
|
||||
)
|
||||
}
|
||||
|
||||
fun getStateLiveData(context: Context) = WorkManager.getInstance(context)
|
||||
.getWorkInfosForUniqueWorkLiveData(NAME)
|
||||
.map { infos -> infos.any { it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED } }
|
||||
|
||||
fun stop(context: Context) {
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package me.capcom.smsgateway.modules.gateway.workers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import me.capcom.smsgateway.App
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayService
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RegistrationWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params), KoinComponent {
|
||||
private val logsSvc: LogsService by inject()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
val token = inputData.getString(DATA_TOKEN)
|
||||
val isUpdate = inputData.getBoolean(DATA_IS_UPDATE, false)
|
||||
|
||||
when (isUpdate) {
|
||||
true -> App.instance.gatewayService.updateDevice(token ?: return Result.success())
|
||||
false -> App.instance.gatewayService.registerDevice(
|
||||
token,
|
||||
GatewayService.RegistrationMode.Anonymous
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
} catch (e: Exception) {
|
||||
logsSvc.insert(
|
||||
priority = LogEntry.Priority.ERROR,
|
||||
module = NAME,
|
||||
message = "Registration failed: ${e.message}",
|
||||
context = mapOf(
|
||||
"token" to inputData.getString(DATA_TOKEN)
|
||||
)
|
||||
)
|
||||
e.printStackTrace()
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NAME = "RegistrationWorker"
|
||||
|
||||
private const val DATA_TOKEN = "token"
|
||||
private const val DATA_IS_UPDATE = "isUpdate"
|
||||
|
||||
fun start(context: Context, token: String?, isUpdate: Boolean) {
|
||||
val work = OneTimeWorkRequestBuilder<RegistrationWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
DATA_TOKEN to token,
|
||||
DATA_IS_UPDATE to isUpdate,
|
||||
)
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
work
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package me.capcom.smsgateway.modules.gateway.workers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayService
|
||||
import me.capcom.smsgateway.modules.messages.MessagesService
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SendStateWorker(appContext: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(appContext, params), KoinComponent {
|
||||
private val messagesService: MessagesService by inject()
|
||||
private val gatewayService: GatewayService by inject()
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
val messageId = inputData.getString(MESSAGE_ID) ?: return Result.failure()
|
||||
val message = messagesService.getMessage(messageId) ?: return Result.failure()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
gatewayService.sendState(message)
|
||||
}
|
||||
return Result.success()
|
||||
} catch (th: Throwable) {
|
||||
th.printStackTrace()
|
||||
return when {
|
||||
this.runAttemptCount < RETRY_COUNT -> Result.retry()
|
||||
else -> Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RETRY_COUNT = 10
|
||||
|
||||
private const val MESSAGE_ID = "messageId"
|
||||
|
||||
fun start(context: Context, messageId: String) {
|
||||
val work = OneTimeWorkRequestBuilder<SendStateWorker>()
|
||||
.setInputData(workDataOf(MESSAGE_ID to messageId))
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueue(work)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package me.capcom.smsgateway.modules.gateway.workers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayService
|
||||
import me.capcom.smsgateway.modules.settings.SettingsService
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SettingsUpdateWorker(appContext: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(appContext, params), KoinComponent {
|
||||
override suspend fun doWork(): Result {
|
||||
val gatewaySvc: GatewayService = get()
|
||||
val settingsSvc: SettingsService = get()
|
||||
|
||||
return try {
|
||||
val settings = gatewaySvc.getSettings()
|
||||
|
||||
settings?.let {
|
||||
settingsSvc.update(settings)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (th: Throwable) {
|
||||
th.printStackTrace()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NAME = "SettingsUpdateWorker"
|
||||
|
||||
fun start(context: Context) {
|
||||
val work = PeriodicWorkRequestBuilder<SettingsUpdateWorker>(
|
||||
24,
|
||||
TimeUnit.HOURS
|
||||
)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
work
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package me.capcom.smsgateway.modules.gateway.workers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayApi
|
||||
import me.capcom.smsgateway.modules.gateway.GatewayService
|
||||
import me.capcom.smsgateway.modules.webhooks.WebHooksService
|
||||
import me.capcom.smsgateway.modules.webhooks.domain.WebHookDTO
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WebhooksUpdateWorker(appContext: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(appContext, params), KoinComponent {
|
||||
override suspend fun doWork(): Result {
|
||||
val gatewaySvc: GatewayService = get()
|
||||
val webhookSvc: WebHooksService = get()
|
||||
|
||||
try {
|
||||
val webhooks = gatewaySvc.getWebHooks().map { it.toDTO() }
|
||||
webhookSvc.sync(EntitySource.Cloud, webhooks)
|
||||
} catch (th: Throwable) {
|
||||
th.printStackTrace()
|
||||
return Result.retry()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun GatewayApi.WebHook.toDTO(): WebHookDTO {
|
||||
return WebHookDTO(
|
||||
id = id,
|
||||
deviceId = null,
|
||||
url = url,
|
||||
event = event,
|
||||
source = EntitySource.Cloud,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NAME = "WebhooksUpdateWorker"
|
||||
|
||||
fun start(context: Context) {
|
||||
val work = PeriodicWorkRequestBuilder<WebhooksUpdateWorker>(
|
||||
24,
|
||||
TimeUnit.HOURS
|
||||
)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
work
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package me.capcom.smsgateway.modules.health
|
||||
|
||||
import me.capcom.smsgateway.modules.connection.ConnectionService
|
||||
import me.capcom.smsgateway.modules.health.domain.HealthResult
|
||||
import me.capcom.smsgateway.modules.health.domain.Status
|
||||
import me.capcom.smsgateway.modules.health.monitors.BatteryMonitor
|
||||
import me.capcom.smsgateway.modules.messages.MessagesService
|
||||
|
||||
class HealthService(
|
||||
private val messagesSvc: MessagesService,
|
||||
private val connectionSvc: ConnectionService,
|
||||
private val batteryMon: BatteryMonitor,
|
||||
) {
|
||||
|
||||
fun healthCheck(): HealthResult {
|
||||
val messagesChecks = messagesSvc.healthCheck()
|
||||
val connectionChecks = connectionSvc.healthCheck()
|
||||
val batteryChecks = batteryMon.healthCheck()
|
||||
|
||||
val allChecks = messagesChecks.mapKeys { "messages:${it.key}" } +
|
||||
connectionChecks.mapKeys { "connection:${it.key}" } +
|
||||
batteryChecks.mapKeys { "battery:${it.key}" }
|
||||
|
||||
return HealthResult(
|
||||
when {
|
||||
allChecks.values.any { it.status == Status.FAIL } -> Status.FAIL
|
||||
allChecks.values.any { it.status == Status.WARN } -> Status.WARN
|
||||
else -> Status.PASS
|
||||
},
|
||||
allChecks
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import me.capcom.smsgateway.modules.health.HealthService
|
||||
import me.capcom.smsgateway.modules.health.monitors.BatteryMonitor
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val healthModule = module {
|
||||
singleOf(::BatteryMonitor)
|
||||
singleOf(::HealthService)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.capcom.smsgateway.modules.health.domain
|
||||
|
||||
data class CheckResult(
|
||||
val status: Status,
|
||||
val observedValue: Long,
|
||||
val observedUnit: String,
|
||||
val description: String,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package me.capcom.smsgateway.modules.health.domain
|
||||
|
||||
data class HealthResult(
|
||||
val status: Status,
|
||||
val checks: Map<String, CheckResult>
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package me.capcom.smsgateway.modules.health.domain
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
enum class Status {
|
||||
@SerializedName("pass")
|
||||
PASS,
|
||||
|
||||
@SerializedName("warn")
|
||||
WARN,
|
||||
|
||||
@SerializedName("fail")
|
||||
FAIL,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.capcom.smsgateway.modules.health.monitors
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import me.capcom.smsgateway.modules.health.domain.CheckResult
|
||||
import me.capcom.smsgateway.modules.health.domain.Status
|
||||
|
||||
class BatteryMonitor(
|
||||
private val context: Context
|
||||
) {
|
||||
fun healthCheck(): Map<String, CheckResult> {
|
||||
val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
|
||||
.let { ifilter ->
|
||||
context.registerReceiver(null, ifilter)
|
||||
}
|
||||
|
||||
val status: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
|
||||
val isCharging: Boolean = status == BatteryManager.BATTERY_STATUS_CHARGING
|
||||
|
||||
// How are we charging?
|
||||
val chargePlug: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
|
||||
val usbCharge: Boolean = chargePlug == BatteryManager.BATTERY_PLUGGED_USB
|
||||
val acCharge: Boolean = chargePlug == BatteryManager.BATTERY_PLUGGED_AC
|
||||
|
||||
val batteryPct: Float? = batteryStatus?.let { intent ->
|
||||
val level: Int = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale: Int = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
level * 100 / scale.toFloat()
|
||||
}
|
||||
|
||||
val levelStatus = batteryPct?.let {
|
||||
when {
|
||||
it < 10 -> Status.FAIL
|
||||
it < 25 -> Status.WARN
|
||||
else -> Status.PASS
|
||||
}
|
||||
} ?: Status.PASS
|
||||
|
||||
return mapOf(
|
||||
"level" to CheckResult(
|
||||
levelStatus,
|
||||
batteryPct?.toLong() ?: 0L,
|
||||
"percent",
|
||||
"Battery level in percent"
|
||||
),
|
||||
"charging" to CheckResult(
|
||||
Status.PASS,
|
||||
when {
|
||||
acCharge -> 2L
|
||||
usbCharge -> 4L
|
||||
else -> 0L
|
||||
} + when (isCharging) {
|
||||
true -> 1L
|
||||
false -> 0L
|
||||
},
|
||||
"flags",
|
||||
"Is the phone charging?"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package me.capcom.smsgateway.modules.incoming
|
||||
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessage
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessageType
|
||||
import me.capcom.smsgateway.modules.incoming.repositories.IncomingMessagesRepository
|
||||
import me.capcom.smsgateway.modules.receiver.data.InboxMessage
|
||||
import java.util.UUID
|
||||
|
||||
class IncomingMessagesService(
|
||||
private val repository: IncomingMessagesRepository,
|
||||
) {
|
||||
fun save(message: InboxMessage, sender: String, recipient: String?, simNumber: Int?) {
|
||||
val type = when (message) {
|
||||
is InboxMessage.Text -> IncomingMessageType.SMS
|
||||
is InboxMessage.Data -> IncomingMessageType.DATA_SMS
|
||||
is InboxMessage.MmsHeaders -> IncomingMessageType.MMS
|
||||
is InboxMessage.MMS -> IncomingMessageType.MMS_DOWNLOADED
|
||||
}
|
||||
|
||||
repository.insert(
|
||||
IncomingMessage(
|
||||
id = buildId(message),
|
||||
type = type,
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
simNumber = simNumber,
|
||||
subscriptionId = message.subscriptionId,
|
||||
contentPreview = message.toPreview(),
|
||||
createdAt = message.date.time,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun count(type: IncomingMessageType?, from: Long, to: Long): Int {
|
||||
return repository.count(type, from, to)
|
||||
}
|
||||
|
||||
suspend fun select(
|
||||
type: IncomingMessageType?,
|
||||
from: Long,
|
||||
to: Long,
|
||||
limit: Int,
|
||||
offset: Int
|
||||
): List<IncomingMessage> {
|
||||
return repository.select(type, from, to, limit, offset)
|
||||
}
|
||||
|
||||
fun getById(id: String): IncomingMessage? {
|
||||
return repository.selectById(id)
|
||||
}
|
||||
|
||||
private fun buildId(message: InboxMessage): String {
|
||||
val base = when (message) {
|
||||
is InboxMessage.MmsHeaders -> message.messageId ?: message.transactionId
|
||||
is InboxMessage.MMS -> message.messageId
|
||||
else -> null
|
||||
}
|
||||
|
||||
return base ?: UUID.nameUUIDFromBytes(
|
||||
"${message.address}-${message.date.time}-${message.subscriptionId}".toByteArray()
|
||||
).toString()
|
||||
}
|
||||
|
||||
private fun InboxMessage.toPreview(): String {
|
||||
return when (this) {
|
||||
is InboxMessage.Text -> text
|
||||
is InboxMessage.Data -> data?.let { "Binary data (${it.size} bytes)" } ?: "Binary data"
|
||||
is InboxMessage.MmsHeaders -> subject ?: "MMS notification"
|
||||
is InboxMessage.MMS -> body ?: subject ?: "MMS content"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.capcom.smsgateway.modules.incoming
|
||||
|
||||
import me.capcom.smsgateway.modules.incoming.repositories.IncomingMessagesRepository
|
||||
import me.capcom.smsgateway.modules.incoming.vm.IncomingMessagesListViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val incomingModule = module {
|
||||
singleOf(::IncomingMessagesRepository)
|
||||
singleOf(::IncomingMessagesService)
|
||||
viewModelOf(::IncomingMessagesListViewModel)
|
||||
}
|
||||
|
||||
const val MODULE_NAME = "incoming"
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.capcom.smsgateway.modules.incoming.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
enum class IncomingMessageType {
|
||||
SMS,
|
||||
DATA_SMS,
|
||||
MMS,
|
||||
MMS_DOWNLOADED,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "incoming_messages",
|
||||
indices = [
|
||||
Index(value = ["createdAt"]),
|
||||
Index(value = ["type"]),
|
||||
]
|
||||
)
|
||||
data class IncomingMessage(
|
||||
@PrimaryKey val id: String,
|
||||
val type: IncomingMessageType,
|
||||
val sender: String,
|
||||
val recipient: String?,
|
||||
val simNumber: Int?,
|
||||
val subscriptionId: Int?,
|
||||
val contentPreview: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.capcom.smsgateway.modules.incoming.db
|
||||
|
||||
data class IncomingMessageTotals(
|
||||
val total: Long,
|
||||
val sms: Long,
|
||||
val dataSms: Long,
|
||||
val mms: Long,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package me.capcom.smsgateway.modules.incoming.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface IncomingMessagesDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(message: IncomingMessage)
|
||||
|
||||
@Query("SELECT * FROM incoming_messages ORDER BY createdAt DESC, id DESC LIMIT :limit")
|
||||
fun selectLast(limit: Int): LiveData<List<IncomingMessage>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM incoming_messages
|
||||
WHERE (:type IS NULL OR type = :type)
|
||||
AND createdAt BETWEEN :from AND :to
|
||||
"""
|
||||
)
|
||||
suspend fun count(type: IncomingMessageType?, from: Long, to: Long): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM incoming_messages
|
||||
WHERE (:type IS NULL OR type = :type)
|
||||
AND createdAt BETWEEN :from AND :to
|
||||
ORDER BY createdAt DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun select(
|
||||
type: IncomingMessageType?,
|
||||
from: Long,
|
||||
to: Long,
|
||||
limit: Int,
|
||||
offset: Int
|
||||
): List<IncomingMessage>
|
||||
|
||||
@Query("SELECT * FROM incoming_messages WHERE id = :id LIMIT 1")
|
||||
fun selectById(id: String): IncomingMessage?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN type = 'SMS' THEN 1 ELSE 0 END), 0) as sms,
|
||||
COALESCE(SUM(CASE WHEN type = 'DATA_SMS' THEN 1 ELSE 0 END), 0) as dataSms,
|
||||
COALESCE(SUM(CASE WHEN type = 'MMS' OR type = 'MMS_DOWNLOADED' THEN 1 ELSE 0 END), 0) as mms
|
||||
FROM incoming_messages
|
||||
"""
|
||||
)
|
||||
fun getStats(): LiveData<IncomingMessageTotals>
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package me.capcom.smsgateway.modules.incoming.repositories
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessage
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessageTotals
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessageType
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessagesDao
|
||||
|
||||
class IncomingMessagesRepository(private val dao: IncomingMessagesDao) {
|
||||
fun selectLast(limit: Int): LiveData<List<IncomingMessage>> =
|
||||
dao.selectLast(limit).distinctUntilChanged()
|
||||
|
||||
suspend fun count(type: IncomingMessageType?, from: Long, to: Long): Int =
|
||||
dao.count(type, from, to)
|
||||
|
||||
suspend fun select(
|
||||
type: IncomingMessageType?,
|
||||
from: Long,
|
||||
to: Long,
|
||||
limit: Int,
|
||||
offset: Int
|
||||
): List<IncomingMessage> =
|
||||
dao.select(type, from, to, limit, offset)
|
||||
|
||||
fun selectById(id: String): IncomingMessage? = dao.selectById(id)
|
||||
|
||||
val totals: LiveData<IncomingMessageTotals> = dao.getStats().distinctUntilChanged()
|
||||
|
||||
fun insert(message: IncomingMessage) = dao.insert(message)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package me.capcom.smsgateway.modules.incoming.vm
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.switchMap
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessage
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessageTotals
|
||||
import me.capcom.smsgateway.modules.incoming.repositories.IncomingMessagesRepository
|
||||
|
||||
class IncomingMessagesListViewModel(
|
||||
private val repository: IncomingMessagesRepository,
|
||||
) : ViewModel() {
|
||||
val totals: LiveData<IncomingMessageTotals> = repository.totals
|
||||
|
||||
private val limit = MutableLiveData(chunkSize)
|
||||
private val _messages = MediatorLiveData<List<IncomingMessage>>()
|
||||
val messages: LiveData<List<IncomingMessage>> = _messages
|
||||
|
||||
private var isLoading = false
|
||||
private var hasMore = true
|
||||
|
||||
init {
|
||||
_messages.addSource(limit.switchMap { repository.selectLast(it) }) {
|
||||
_messages.value = it
|
||||
hasMore = it.size >= (limit.value ?: chunkSize)
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore(index: Int = 0) {
|
||||
val currentLimit = limit.value ?: 0
|
||||
if (currentLimit >= index + chunkSize || isLoading || !hasMore) return
|
||||
|
||||
isLoading = true
|
||||
limit.value = currentLimit + chunkSize
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val chunkSize = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package me.capcom.smsgateway.modules.localserver
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import me.capcom.smsgateway.modules.events.EventBus
|
||||
import me.capcom.smsgateway.modules.localserver.events.IPReceivedEvent
|
||||
import me.capcom.smsgateway.providers.LocalIPProvider
|
||||
import me.capcom.smsgateway.providers.PublicIPProvider
|
||||
|
||||
class LocalServerService(
|
||||
private val settings: LocalServerSettings,
|
||||
private val events: EventBus,
|
||||
) {
|
||||
|
||||
private fun getDeviceId(context: Context): String {
|
||||
val firstInstallTime = context.packageManager.getPackageInfo(
|
||||
context.packageName,
|
||||
0
|
||||
).firstInstallTime
|
||||
val deviceName = "${Build.MANUFACTURER}/${Build.PRODUCT}"
|
||||
|
||||
return deviceName.hashCode().toULong()
|
||||
.toString(16).padStart(16, '0') + firstInstallTime.toULong()
|
||||
.toString(16).padStart(16, '0')
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
if (!settings.enabled) return
|
||||
settings.deviceId = settings.deviceId ?: getDeviceId(context)
|
||||
|
||||
WebService.start(context)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val localIP = LocalIPProvider(context).getIP()
|
||||
val remoteIP = PublicIPProvider().getIP()
|
||||
|
||||
events.emit(IPReceivedEvent(localIP, remoteIP))
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
WebService.stop(context)
|
||||
}
|
||||
|
||||
fun isActiveLiveData(context: Context) = WebService.STATUS
|
||||
|
||||
companion object {
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.capcom.smsgateway.modules.localserver
|
||||
|
||||
import com.aventrix.jnanoid.jnanoid.NanoIdUtils
|
||||
import me.capcom.smsgateway.modules.settings.KeyValueStorage
|
||||
import me.capcom.smsgateway.modules.settings.get
|
||||
|
||||
class LocalServerSettings(
|
||||
private val storage: KeyValueStorage,
|
||||
) {
|
||||
var enabled: Boolean
|
||||
get() = storage.get<Boolean>(ENABLED) ?: false
|
||||
set(value) = storage.set(ENABLED, value)
|
||||
|
||||
var deviceId: String?
|
||||
get() = storage.get<String?>(DEVICE_ID)
|
||||
set(value) = storage.set(DEVICE_ID, value)
|
||||
|
||||
val port: Int
|
||||
get() = storage.get<Int>(PORT) ?: 8080
|
||||
|
||||
val username: String
|
||||
get() = storage.get<String?>(USERNAME)
|
||||
?: "sms"
|
||||
val password: String
|
||||
get() = storage.get<String?>(PASSWORD)
|
||||
?: NanoIdUtils.randomNanoId(
|
||||
NanoIdUtils.DEFAULT_NUMBER_GENERATOR,
|
||||
NanoIdUtils.DEFAULT_ALPHABET,
|
||||
8
|
||||
).also { storage.set(PASSWORD, it) }
|
||||
|
||||
|
||||
val jwtSecret: String
|
||||
get() = storage.get<String?>(JWT_SECRET)
|
||||
?: NanoIdUtils.randomNanoId(
|
||||
NanoIdUtils.DEFAULT_NUMBER_GENERATOR,
|
||||
NanoIdUtils.DEFAULT_ALPHABET,
|
||||
48
|
||||
).also { storage.set(JWT_SECRET, it) }
|
||||
|
||||
var jwtTtlSeconds: Long
|
||||
get() = storage.get<Long>(JWT_TTL_SECONDS) ?: (24L * 60L * 60L)
|
||||
set(value) = storage.set(JWT_TTL_SECONDS, value)
|
||||
|
||||
fun regenerateJwtSecret(): String {
|
||||
return NanoIdUtils.randomNanoId(
|
||||
NanoIdUtils.DEFAULT_NUMBER_GENERATOR,
|
||||
NanoIdUtils.DEFAULT_ALPHABET,
|
||||
48
|
||||
).also { storage.set(JWT_SECRET, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ENABLED = "ENABLED"
|
||||
|
||||
private const val DEVICE_ID = "DEVICE_ID"
|
||||
private const val PORT = "PORT"
|
||||
private const val USERNAME = "USERNAME"
|
||||
private const val PASSWORD = "PASSWORD"
|
||||
private const val JWT_SECRET = "JWT_SECRET"
|
||||
private const val JWT_TTL_SECONDS = "JWT_TTL_SECONDS"
|
||||
|
||||
const val MAX_JWT_TTL_SECONDS: Long = 365L * 24L * 60L * 60L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.capcom.smsgateway.modules.localserver
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val localserverModule = module {
|
||||
singleOf(::LocalServerService)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package me.capcom.smsgateway.modules.localserver
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.toHttpDate
|
||||
import io.ktor.serialization.gson.gson
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.createApplicationPlugin
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.auth.Authentication
|
||||
import io.ktor.server.auth.UserIdPrincipal
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.auth.basic
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.jwt.jwt
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.plugins.NotFoundException
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.util.date.GMTDate
|
||||
import me.capcom.smsgateway.R
|
||||
import me.capcom.smsgateway.domain.HealthResponse
|
||||
import me.capcom.smsgateway.extensions.configure
|
||||
import me.capcom.smsgateway.modules.health.HealthService
|
||||
import me.capcom.smsgateway.modules.health.domain.Status
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.JwtService
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.localserver.domain.Device
|
||||
import me.capcom.smsgateway.modules.localserver.routes.AuthRoutes
|
||||
import me.capcom.smsgateway.modules.localserver.routes.DocsRoutes
|
||||
import me.capcom.smsgateway.modules.localserver.routes.InboxRoutes
|
||||
import me.capcom.smsgateway.modules.localserver.routes.LogsRoutes
|
||||
import me.capcom.smsgateway.modules.localserver.routes.MessagesRoutes
|
||||
import me.capcom.smsgateway.modules.localserver.routes.WebhooksRoutes
|
||||
import me.capcom.smsgateway.modules.notifications.NotificationsService
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.Date
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class WebService : Service() {
|
||||
|
||||
private val settings: LocalServerSettings by inject()
|
||||
private val notificationsService: NotificationsService by inject()
|
||||
private val healthService: HealthService by inject()
|
||||
private val jwtService: JwtService by lazy { JwtService(get(), get(), get()) }
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock by lazy {
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.javaClass.name)
|
||||
}
|
||||
}
|
||||
private val wifiLock: WifiManager.WifiLock by lazy {
|
||||
(getSystemService(Context.WIFI_SERVICE) as WifiManager).createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
this.javaClass.name
|
||||
)
|
||||
}
|
||||
|
||||
private val server by lazy {
|
||||
embeddedServer(
|
||||
Netty,
|
||||
port = port,
|
||||
watchPaths = emptyList(),
|
||||
) {
|
||||
install(Authentication) {
|
||||
basic("auth-basic") {
|
||||
realm = "Access to SMS Gateway"
|
||||
validate { credentials ->
|
||||
when {
|
||||
credentials.name == username
|
||||
&& credentials.password == password -> UserIdPrincipal(
|
||||
credentials.name
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
jwt("auth-jwt") {
|
||||
realm = "Access to SMS Gateway"
|
||||
verifier { jwtService.verifier() }
|
||||
validate { credential ->
|
||||
val tokenId = credential.payload.id ?: return@validate null
|
||||
if (jwtService.isTokenRevoked(tokenId)) {
|
||||
return@validate null
|
||||
}
|
||||
|
||||
JWTPrincipal(credential.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
gson {
|
||||
if (me.capcom.smsgateway.BuildConfig.DEBUG) {
|
||||
setPrettyPrinting()
|
||||
}
|
||||
configure()
|
||||
}
|
||||
}
|
||||
install(StatusPages) {
|
||||
exception<Throwable> { call, cause ->
|
||||
call.respond(
|
||||
when (cause) {
|
||||
is IllegalArgumentException -> HttpStatusCode.BadRequest
|
||||
is BadRequestException -> HttpStatusCode.BadRequest
|
||||
is NotFoundException -> HttpStatusCode.NotFound
|
||||
else -> HttpStatusCode.InternalServerError
|
||||
},
|
||||
mapOf("message" to cause.description)
|
||||
)
|
||||
}
|
||||
}
|
||||
install(createApplicationPlugin(name = "DateHeader") {
|
||||
onCall { call ->
|
||||
call.response.header(
|
||||
"Date",
|
||||
GMTDate(null).toHttpDate()
|
||||
)
|
||||
}
|
||||
})
|
||||
routing {
|
||||
get("/health") {
|
||||
val healthResult = healthService.healthCheck()
|
||||
call.respond(
|
||||
when (healthResult.status) {
|
||||
Status.FAIL -> HttpStatusCode.InternalServerError
|
||||
Status.WARN -> HttpStatusCode.OK
|
||||
Status.PASS -> HttpStatusCode.OK
|
||||
},
|
||||
HealthResponse(healthResult)
|
||||
)
|
||||
}
|
||||
authenticate("auth-basic", "auth-jwt") {
|
||||
get("/") {
|
||||
call.respond(mapOf("status" to "ok", "model" to Build.MODEL))
|
||||
}
|
||||
route("/device") {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.DevicesList)) return@get
|
||||
val firstInstallTime = packageManager.getPackageInfo(
|
||||
packageName,
|
||||
0
|
||||
).firstInstallTime
|
||||
val deviceName = "${Build.MANUFACTURER}/${Build.PRODUCT}"
|
||||
val device = Device(
|
||||
requireNotNull(settings.deviceId),
|
||||
deviceName,
|
||||
Date(firstInstallTime),
|
||||
Date(),
|
||||
Date()
|
||||
)
|
||||
|
||||
call.respond(listOf(device))
|
||||
}
|
||||
}
|
||||
MessagesRoutes(applicationContext, get(), get(), get()).let {
|
||||
route("/message") {
|
||||
it.register(this)
|
||||
}
|
||||
route("/messages") {
|
||||
it.register(this)
|
||||
}
|
||||
}
|
||||
InboxRoutes(applicationContext, get(), get(), get()).let {
|
||||
route("/inbox") {
|
||||
it.register(this)
|
||||
}
|
||||
}
|
||||
WebhooksRoutes(get(), get()).let {
|
||||
route("/webhook") {
|
||||
it.register(this)
|
||||
}
|
||||
route("/webhooks") {
|
||||
it.register(this)
|
||||
}
|
||||
}
|
||||
|
||||
route("/logs") {
|
||||
LogsRoutes(get()).register(this)
|
||||
}
|
||||
route("/settings") {
|
||||
me.capcom.smsgateway.modules.localserver.routes.SettingsRoutes(get())
|
||||
.register(this)
|
||||
}
|
||||
route("/docs") {
|
||||
DocsRoutes(get()).register(this)
|
||||
}
|
||||
route("/auth") {
|
||||
AuthRoutes(jwtService).register(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val port = settings.port
|
||||
private val username = settings.username
|
||||
private val password = settings.password
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
server.start()
|
||||
wakeLock.acquire()
|
||||
wifiLock.acquire()
|
||||
|
||||
status.postValue(true)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val notification = notificationsService.makeNotification(
|
||||
this,
|
||||
NotificationsService.NOTIFICATION_ID_LOCAL_SERVICE,
|
||||
getString(
|
||||
R.string.sms_gateway_is_running_on_port,
|
||||
port
|
||||
)
|
||||
)
|
||||
|
||||
startForeground(NotificationsService.NOTIFICATION_ID_LOCAL_SERVICE, notification)
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
TODO("Return the communication channel to the service.")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
wifiLock.release()
|
||||
wakeLock.release()
|
||||
thread { server.stop() }
|
||||
|
||||
stopForeground(true)
|
||||
|
||||
status.postValue(false)
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val status = MutableLiveData<Boolean>(false)
|
||||
val STATUS: LiveData<Boolean> = status
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, WebService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, WebService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Throwable.description: String
|
||||
get() {
|
||||
return (localizedMessage ?: message ?: toString()) +
|
||||
(cause?.let { ": " + it.description } ?: "")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package me.capcom.smsgateway.modules.localserver.auth
|
||||
|
||||
enum class AuthScopes(val value: String) {
|
||||
AllAny("all:any"),
|
||||
|
||||
MessagesSend("messages:send"),
|
||||
MessagesRead("messages:read"),
|
||||
MessagesExport("messages:export"),
|
||||
|
||||
InboxList("inbox:list"),
|
||||
InboxRead("inbox:read"),
|
||||
InboxRefresh("inbox:refresh"),
|
||||
|
||||
DevicesList("devices:list"),
|
||||
|
||||
WebhooksList("webhooks:list"),
|
||||
WebhooksWrite("webhooks:write"),
|
||||
WebhooksDelete("webhooks:delete"),
|
||||
|
||||
SettingsRead("settings:read"),
|
||||
SettingsWrite("settings:write"),
|
||||
|
||||
LogsRead("logs:read"),
|
||||
|
||||
TokensManage("tokens:manage");
|
||||
|
||||
companion object {
|
||||
private val supportedValues: Set<String> = values().mapTo(HashSet()) { it.value }
|
||||
fun firstUnsupported(scopes: Iterable<String>): String? {
|
||||
return scopes.firstOrNull { it !in supportedValues }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package me.capcom.smsgateway.modules.localserver.auth
|
||||
|
||||
import android.content.Context
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import com.aventrix.jnanoid.jnanoid.NanoIdUtils
|
||||
import me.capcom.smsgateway.data.dao.TokensDao
|
||||
import me.capcom.smsgateway.data.entities.Token
|
||||
import me.capcom.smsgateway.modules.localserver.LocalServerSettings
|
||||
import java.util.Date
|
||||
|
||||
data class GeneratedToken(
|
||||
val id: String,
|
||||
val accessToken: String,
|
||||
val expiresAt: Date,
|
||||
)
|
||||
|
||||
class JwtService(
|
||||
context: Context,
|
||||
private val settings: LocalServerSettings,
|
||||
private val tokensDao: TokensDao,
|
||||
) {
|
||||
private val issuer = context.packageName
|
||||
|
||||
private val algorithm: Algorithm
|
||||
get() = Algorithm.HMAC256(settings.jwtSecret)
|
||||
|
||||
suspend fun generateToken(scopes: List<String>, ttlSeconds: Long?): GeneratedToken {
|
||||
val effectiveScopes = scopes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
require(effectiveScopes.isNotEmpty()) { "scopes must not be empty" }
|
||||
require(AuthScopes.firstUnsupported(effectiveScopes) == null) { "unsupported scope provided" }
|
||||
|
||||
val now = Date()
|
||||
val ttl = ttlSeconds ?: settings.jwtTtlSeconds
|
||||
require(ttl > 0) { "ttl must be > 0" }
|
||||
require(ttl <= LocalServerSettings.MAX_JWT_TTL_SECONDS) { "ttl exceeds maximum allowed value" }
|
||||
|
||||
val ttlMillis = ttl * 1000L
|
||||
|
||||
val tokenId = NanoIdUtils.randomNanoId()
|
||||
val expiresAt = Date(now.time + ttlMillis)
|
||||
|
||||
val token = JWT.create()
|
||||
.withJWTId(tokenId)
|
||||
.withIssuer(issuer)
|
||||
.withIssuedAt(now)
|
||||
.withExpiresAt(expiresAt)
|
||||
.withClaim("scopes", effectiveScopes)
|
||||
.sign(algorithm)
|
||||
|
||||
cleanupExpiredTokens()
|
||||
tokensDao.insert(Token(tokenId, expiresAt.time))
|
||||
|
||||
return GeneratedToken(tokenId, token, expiresAt)
|
||||
}
|
||||
|
||||
fun verifier() = JWT.require(algorithm)
|
||||
.withIssuer(issuer)
|
||||
.build()
|
||||
|
||||
suspend fun revokeToken(jti: String) {
|
||||
tokensDao.revoke(jti)
|
||||
}
|
||||
|
||||
suspend fun isTokenRevoked(jti: String): Boolean {
|
||||
return tokensDao.isRevoked(jti)
|
||||
}
|
||||
|
||||
suspend fun cleanupExpiredTokens() {
|
||||
tokensDao.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package me.capcom.smsgateway.modules.localserver.auth
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.auth.UserIdPrincipal
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.principal
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.util.pipeline.PipelineContext
|
||||
|
||||
suspend fun PipelineContext<Unit, ApplicationCall>.requireScope(scope: AuthScopes): Boolean {
|
||||
if (call.principal<UserIdPrincipal>() != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
val jwtPrincipal = call.principal<JWTPrincipal>()
|
||||
if (jwtPrincipal == null) {
|
||||
call.respond(HttpStatusCode.Unauthorized, mapOf("message" to "Unauthorized"))
|
||||
return false
|
||||
}
|
||||
|
||||
val scopes = try {
|
||||
jwtPrincipal.payload
|
||||
.getClaim("scopes")
|
||||
.asList(String::class.java)
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d("ScopeAuthorization", "Failed to parse scopes claim", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
if (AuthScopes.AllAny.value in scopes || scope.value in scopes) {
|
||||
return true
|
||||
}
|
||||
|
||||
call.respond(HttpStatusCode.Forbidden, mapOf("message" to "Insufficient permissions"))
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class Device(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date,
|
||||
val lastSeen: Date
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import java.util.Date
|
||||
|
||||
data class GetLogsResponse(
|
||||
val priority: LogEntry.Priority,
|
||||
val module: String,
|
||||
val message: String,
|
||||
val id: Long = 0,
|
||||
val context: JsonElement? = null,
|
||||
val createdAt: Date,
|
||||
) {
|
||||
companion object {
|
||||
fun from(log: LogEntry) = GetLogsResponse(
|
||||
priority = log.priority,
|
||||
module = log.module,
|
||||
message = log.message,
|
||||
id = log.id,
|
||||
context = log.context,
|
||||
createdAt = Date(log.createdAt)
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class PostMessagesInboxExportRequest(
|
||||
val since: Date,
|
||||
val until: Date,
|
||||
) {
|
||||
val period: Pair<Date, Date>
|
||||
get() = since to until
|
||||
|
||||
fun validate(): PostMessagesInboxExportRequest {
|
||||
if (since == null) {
|
||||
throw IllegalArgumentException("since is required")
|
||||
}
|
||||
|
||||
if (until == null) {
|
||||
throw IllegalArgumentException("until is required")
|
||||
}
|
||||
|
||||
if (since.after(until)) {
|
||||
throw IllegalArgumentException("since must be before until")
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class TokenRequest(
|
||||
@SerializedName("ttl")
|
||||
val ttl: Long?,
|
||||
@SerializedName("scopes")
|
||||
val scopes: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.util.Date
|
||||
|
||||
data class TokenResponse(
|
||||
@SerializedName("id")
|
||||
val id: String,
|
||||
@SerializedName("token_type")
|
||||
val tokenType: String,
|
||||
@SerializedName("access_token")
|
||||
val accessToken: String,
|
||||
@SerializedName("expires_at")
|
||||
val expiresAt: Date,
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain.messages
|
||||
|
||||
data class DataMessage(
|
||||
val data: String, // Base64-encoded payload
|
||||
val port: Int, // Destination port (0-65535)
|
||||
)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain.messages
|
||||
|
||||
data class HashedMessage(
|
||||
val hash: String,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain.messages
|
||||
|
||||
import me.capcom.smsgateway.domain.ProcessingState
|
||||
import java.util.Date
|
||||
|
||||
open class Message(
|
||||
val id: String,
|
||||
val deviceId: String,
|
||||
val state: ProcessingState,
|
||||
val isHashed: Boolean,
|
||||
val isEncrypted: Boolean,
|
||||
val textMessage: TextMessage?,
|
||||
val dataMessage: DataMessage?,
|
||||
val hashedMessage: HashedMessage?,
|
||||
val recipients: List<Recipient>,
|
||||
val states: Map<ProcessingState, Date>,
|
||||
) {
|
||||
data class Recipient(
|
||||
val phoneNumber: String,
|
||||
val state: ProcessingState,
|
||||
val error: String?,
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain.messages
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.util.Date
|
||||
|
||||
|
||||
data class PostMessageRequest(
|
||||
val id: String?,
|
||||
@Deprecated("Use textMessage instead")
|
||||
val message: String?,
|
||||
val phoneNumbers: List<String>,
|
||||
val simNumber: Int?,
|
||||
val withDeliveryReport: Boolean?,
|
||||
val isEncrypted: Boolean?,
|
||||
val priority: Byte = 0,
|
||||
|
||||
val textMessage: TextMessage? = null,
|
||||
val dataMessage: DataMessage? = null,
|
||||
|
||||
val deviceId: String? = null,
|
||||
|
||||
@SerializedName("ttl")
|
||||
private val _ttl: Long?,
|
||||
@SerializedName("validUntil")
|
||||
private val _validUntil: Date?
|
||||
) {
|
||||
val validUntil: Date?
|
||||
get() {
|
||||
if (_ttl != null && _validUntil != null) {
|
||||
throw IllegalArgumentException("fields conflict: ttl and validUntil")
|
||||
}
|
||||
|
||||
val validUntil = _validUntil
|
||||
?: _ttl?.let { Date(System.currentTimeMillis() + (it * 1000L)) }
|
||||
|
||||
if (validUntil?.before(Date()) == true) {
|
||||
throw IllegalArgumentException("message already expired")
|
||||
}
|
||||
|
||||
return validUntil
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package me.capcom.smsgateway.modules.localserver.domain.messages
|
||||
|
||||
data class TextMessage(
|
||||
val text: String,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package me.capcom.smsgateway.modules.localserver.events
|
||||
|
||||
import me.capcom.smsgateway.modules.events.AppEvent
|
||||
|
||||
class IPReceivedEvent(
|
||||
val localIP: String?,
|
||||
val publicIP: String?,
|
||||
): AppEvent(NAME) {
|
||||
companion object {
|
||||
const val NAME = "IPReceivedEvent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.JwtService
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.localserver.domain.TokenRequest
|
||||
import me.capcom.smsgateway.modules.localserver.domain.TokenResponse
|
||||
|
||||
class AuthRoutes(
|
||||
private val jwtService: JwtService,
|
||||
) {
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
route("token") {
|
||||
tokenRoutes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.tokenRoutes() {
|
||||
post {
|
||||
if (!requireScope(AuthScopes.TokensManage)) return@post
|
||||
val request = call.receive<TokenRequest>()
|
||||
val token = jwtService.generateToken(request.scopes, request.ttl)
|
||||
call.respond(
|
||||
HttpStatusCode.Created,
|
||||
TokenResponse(
|
||||
id = token.id,
|
||||
tokenType = "Bearer",
|
||||
accessToken = token.accessToken,
|
||||
expiresAt = token.expiresAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
delete("/{jti}") {
|
||||
if (!requireScope(AuthScopes.TokensManage)) return@delete
|
||||
val jti = call.parameters["jti"]?.trim()
|
||||
if (jti.isNullOrEmpty()) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("message" to "jti is required"))
|
||||
return@delete
|
||||
}
|
||||
|
||||
jwtService.revokeToken(jti)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import android.content.Context
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.appendPathSegments
|
||||
import io.ktor.http.defaultForFilePath
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondBytes
|
||||
import io.ktor.server.response.respondRedirect
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.util.pipeline.PipelineContext
|
||||
import java.io.IOException
|
||||
|
||||
class DocsRoutes(
|
||||
private val applicationContext: Context,
|
||||
) {
|
||||
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
docsRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.docsRoutes() {
|
||||
get {
|
||||
redirect()
|
||||
}
|
||||
get("/") {
|
||||
redirect()
|
||||
}
|
||||
get("{path...}") {
|
||||
val path =
|
||||
call.parameters.getAll("path")?.joinToString("/")
|
||||
?.takeIf { it.isNotBlank() } ?: "index.html"
|
||||
// Prevent path traversal attacks
|
||||
if (path.contains("..")) {
|
||||
call.respond(HttpStatusCode.BadRequest)
|
||||
return@get
|
||||
}
|
||||
|
||||
val assetPath = "api/$path"
|
||||
|
||||
try {
|
||||
val inputStream = applicationContext.assets.open(assetPath)
|
||||
val bytes = inputStream.readBytes()
|
||||
call.respondBytes(
|
||||
bytes,
|
||||
ContentType.defaultForFilePath(assetPath)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PipelineContext<Unit, ApplicationCall>.redirect() {
|
||||
call.respondRedirect(true) {
|
||||
appendPathSegments("index.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import android.content.Context
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import me.capcom.smsgateway.helpers.DateTimeParser
|
||||
import me.capcom.smsgateway.modules.incoming.IncomingMessagesService
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessage
|
||||
import me.capcom.smsgateway.modules.incoming.db.IncomingMessageType
|
||||
import me.capcom.smsgateway.modules.localserver.LocalServerSettings
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.localserver.domain.PostMessagesInboxExportRequest
|
||||
import me.capcom.smsgateway.modules.receiver.ReceiverService
|
||||
import java.util.Date
|
||||
|
||||
class InboxRoutes(
|
||||
private val context: Context,
|
||||
private val incomingMessagesService: IncomingMessagesService,
|
||||
private val receiverService: ReceiverService,
|
||||
private val settings: LocalServerSettings,
|
||||
) {
|
||||
fun register(routing: Route) {
|
||||
routing.inboxRoutes(context)
|
||||
}
|
||||
|
||||
private fun Route.inboxRoutes(context: Context) {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.InboxList)) return@get
|
||||
|
||||
val rawType = call.request.queryParameters["type"]?.takeIf { it.isNotBlank() }
|
||||
val type = try {
|
||||
rawType?.let { IncomingMessageType.valueOf(it) }
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid type")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||
|
||||
if (limit !in 1..500) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "limit must be between 1 and 500")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
if (offset < 0) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "offset must be >= 0")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
val fromRaw = call.request.queryParameters["from"]
|
||||
val toRaw = call.request.queryParameters["to"]
|
||||
|
||||
val from = if (fromRaw == null) {
|
||||
0L
|
||||
} else {
|
||||
DateTimeParser.parseIsoDateTime(fromRaw)?.time ?: run {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid from datetime")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
}
|
||||
|
||||
val to = if (toRaw == null) {
|
||||
Date().time
|
||||
} else {
|
||||
DateTimeParser.parseIsoDateTime(toRaw)?.time ?: run {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid to datetime")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
}
|
||||
|
||||
val deviceId = call.request.queryParameters["deviceId"]
|
||||
if (deviceId != null && deviceId != settings.deviceId) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid device ID")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
if (from > to) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Start date cannot be after end date")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
val total = try {
|
||||
incomingMessagesService.count(type, from, to)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to count incoming messages: ${e.message}")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
val messages = try {
|
||||
incomingMessagesService.select(type, from, to, limit, offset)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to retrieve incoming messages: ${e.message}")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
call.response.headers.append("X-Total-Count", total.toString())
|
||||
|
||||
call.respond(messages.map { it.toDomain() } as GetIncomingMessagesResponse)
|
||||
}
|
||||
|
||||
// get("{id}") {
|
||||
// if (!requireScope(AuthScopes.InboxRead)) return@get
|
||||
// val id = call.parameters["id"]
|
||||
// ?: return@get call.respond(HttpStatusCode.BadRequest)
|
||||
//
|
||||
// val message = try {
|
||||
// incomingMessagesService.getById(id)
|
||||
// ?: return@get call.respond(HttpStatusCode.NotFound)
|
||||
// } catch (e: Throwable) {
|
||||
// return@get call.respond(
|
||||
// HttpStatusCode.InternalServerError,
|
||||
// mapOf("message" to e.message)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// call.respond(message.toDomain())
|
||||
// }
|
||||
|
||||
post("refresh") {
|
||||
if (!requireScope(AuthScopes.InboxRefresh)) return@post
|
||||
|
||||
val request = try {
|
||||
call.receive<PostMessagesInboxExportRequest>().validate()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to (e.message ?: "Invalid request"))
|
||||
)
|
||||
return@post
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("message" to "Invalid request body"))
|
||||
return@post
|
||||
}
|
||||
|
||||
try {
|
||||
receiverService.export(context, request.period, false)
|
||||
call.respond(HttpStatusCode.Accepted)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to refresh inbox: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class InboxMessage(
|
||||
val id: String,
|
||||
val type: IncomingMessageType,
|
||||
val sender: String,
|
||||
val recipient: String?,
|
||||
val simNumber: Int?,
|
||||
val contentPreview: String,
|
||||
val createdAt: Date,
|
||||
)
|
||||
|
||||
private fun IncomingMessage.toDomain() = InboxMessage(
|
||||
id = id,
|
||||
type = type,
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
simNumber = simNumber,
|
||||
contentPreview = contentPreview,
|
||||
createdAt = Date(createdAt),
|
||||
)
|
||||
}
|
||||
|
||||
typealias GetIncomingMessagesResponse = List<InboxRoutes.InboxMessage>
|
||||
@@ -0,0 +1,42 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import me.capcom.smsgateway.helpers.DateTimeParser
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.localserver.domain.GetLogsResponse
|
||||
import me.capcom.smsgateway.modules.logs.LogsService
|
||||
|
||||
class LogsRoutes(
|
||||
private val logsService: LogsService,
|
||||
) {
|
||||
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
logsRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.logsRoutes() {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.LogsRead)) return@get
|
||||
try {
|
||||
val from = call.request.queryParameters["from"]?.let {
|
||||
DateTimeParser.parseIsoDateTime(it)?.time
|
||||
}
|
||||
val to = call.request.queryParameters["to"]?.let {
|
||||
DateTimeParser.parseIsoDateTime(it)?.time
|
||||
}
|
||||
|
||||
call.respond(logsService.select(from, to).map { GetLogsResponse.from(it) })
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("message" to e.message))
|
||||
return@get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import android.content.Context
|
||||
import com.aventrix.jnanoid.jnanoid.NanoIdUtils
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import me.capcom.smsgateway.data.entities.MessageWithRecipients
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.domain.MessageContent
|
||||
import me.capcom.smsgateway.domain.ProcessingState
|
||||
import me.capcom.smsgateway.helpers.DateTimeParser
|
||||
import me.capcom.smsgateway.modules.localserver.LocalServerSettings
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.localserver.domain.PostMessagesInboxExportRequest
|
||||
import me.capcom.smsgateway.modules.localserver.domain.messages.DataMessage
|
||||
import me.capcom.smsgateway.modules.localserver.domain.messages.PostMessageRequest
|
||||
import me.capcom.smsgateway.modules.localserver.domain.messages.TextMessage
|
||||
import me.capcom.smsgateway.modules.messages.MessagesService
|
||||
import me.capcom.smsgateway.modules.messages.data.Message
|
||||
import me.capcom.smsgateway.modules.messages.data.SendParams
|
||||
import me.capcom.smsgateway.modules.messages.data.SendRequest
|
||||
import me.capcom.smsgateway.modules.messages.exceptions.ConflictException
|
||||
import me.capcom.smsgateway.modules.receiver.ReceiverService
|
||||
import java.util.Date
|
||||
|
||||
class MessagesRoutes(
|
||||
private val context: Context,
|
||||
private val messagesService: MessagesService,
|
||||
private val receiverService: ReceiverService,
|
||||
private val settings: LocalServerSettings,
|
||||
) {
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
messagesRoutes()
|
||||
route("/inbox") {
|
||||
inboxRoutes(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.messagesRoutes() {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.MessagesRead)) return@get
|
||||
// Parse and validate parameters
|
||||
val state = call.request.queryParameters["state"]?.takeIf { it.isNotEmpty() }
|
||||
?.let { ProcessingState.valueOf(it) }
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
|
||||
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
|
||||
val includeContent = call.request.queryParameters["includeContent"]?.let {
|
||||
it.toBooleanStrictOrNull() ?: run {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "includeContent must be true or false")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
} ?: false
|
||||
|
||||
// Parse date range parameters
|
||||
val from = call.request.queryParameters["from"]?.let {
|
||||
DateTimeParser.parseIsoDateTime(it)?.time
|
||||
} ?: 0
|
||||
val to = call.request.queryParameters["to"]?.let {
|
||||
DateTimeParser.parseIsoDateTime(it)?.time
|
||||
} ?: Date().time
|
||||
|
||||
val deviceId = call.request.queryParameters["deviceId"]
|
||||
if (deviceId != null && deviceId != settings.deviceId) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid device ID")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
// Ensure start date is before end date
|
||||
if (from > to) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Start date cannot be after end date")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
val total = try {
|
||||
messagesService.countMessages(EntitySource.Local, state, from, to)
|
||||
} catch (e: Throwable) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to count messages: ${e.message}")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
// Get messages with pagination
|
||||
val messages = try {
|
||||
messagesService.selectMessages(EntitySource.Local, state, from, to, limit, offset)
|
||||
} catch (e: Throwable) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to retrieve messages: ${e.message}")
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
call.response.headers.append("X-Total-Count", total.toString())
|
||||
|
||||
call.respond(
|
||||
messages.map {
|
||||
it.toDomain(requireNotNull(settings.deviceId), includeContent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
if (!requireScope(AuthScopes.MessagesSend)) return@post
|
||||
val request = call.receive<PostMessageRequest>()
|
||||
|
||||
if (request.deviceId?.let { it == settings.deviceId } == false) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid device ID")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
val messageTypes =
|
||||
listOfNotNull(request.textMessage, request.dataMessage, request.message)
|
||||
when {
|
||||
messageTypes.isEmpty() -> {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Must specify exactly one of: textMessage, dataMessage, or message")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
messageTypes.size > 1 -> {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Cannot specify multiple message types simultaneously")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
}
|
||||
|
||||
// Validate message parameters
|
||||
request.message?.let { msg ->
|
||||
// Text validation
|
||||
if (msg.isEmpty()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Text message is empty")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
}
|
||||
|
||||
// Validate data message parameters
|
||||
request.dataMessage?.let { dataMsg ->
|
||||
// Port validation
|
||||
if (dataMsg.port < 0 || dataMsg.port > 65535) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Port must be between 0 and 65535")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
// Data validation (only for non-empty check)
|
||||
if (dataMsg.data.isEmpty()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Data message cannot be empty")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
}
|
||||
|
||||
// Validate text message parameters
|
||||
request.textMessage?.let { textMsg ->
|
||||
// Text validation
|
||||
if (textMsg.text.isEmpty()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Text message is empty")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
}
|
||||
|
||||
// Existing validation
|
||||
if (request.phoneNumbers.isEmpty()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "phoneNumbers is empty")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
if (request.simNumber != null && request.simNumber < 1) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "simNumber must be >= 1")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
val skipPhoneValidation =
|
||||
call.request.queryParameters["skipPhoneValidation"]
|
||||
?.toBooleanStrict() ?: false
|
||||
|
||||
// Create message content based on type
|
||||
val messageContent = when {
|
||||
request.message != null -> {
|
||||
MessageContent.Text(request.message)
|
||||
}
|
||||
|
||||
request.textMessage != null -> {
|
||||
MessageContent.Text(request.textMessage.text)
|
||||
}
|
||||
|
||||
request.dataMessage != null -> {
|
||||
MessageContent.Data(
|
||||
request.dataMessage.data,
|
||||
request.dataMessage.port.toUShort()
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// This case should be caught by validation, but just in case
|
||||
throw IllegalStateException("Unknown message type")
|
||||
}
|
||||
}
|
||||
|
||||
val sendRequest = SendRequest(
|
||||
EntitySource.Local,
|
||||
Message(
|
||||
request.id ?: NanoIdUtils.randomNanoId(),
|
||||
content = messageContent,
|
||||
phoneNumbers = request.phoneNumbers,
|
||||
isEncrypted = request.isEncrypted ?: false,
|
||||
createdAt = Date(),
|
||||
),
|
||||
SendParams(
|
||||
request.withDeliveryReport ?: true,
|
||||
skipPhoneValidation = skipPhoneValidation,
|
||||
simNumber = request.simNumber,
|
||||
validUntil = request.validUntil,
|
||||
priority = request.priority,
|
||||
)
|
||||
)
|
||||
|
||||
val message = try {
|
||||
messagesService.enqueueMessage(sendRequest)
|
||||
} catch (e: ConflictException) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
mapOf("message" to e.message)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.Accepted,
|
||||
message.toDomain(requireNotNull(settings.deviceId), true)
|
||||
)
|
||||
}
|
||||
get("{id}") {
|
||||
if (!requireScope(AuthScopes.MessagesRead)) return@get
|
||||
val id = call.parameters["id"]
|
||||
?: return@get call.respond(HttpStatusCode.BadRequest)
|
||||
|
||||
val message = try {
|
||||
messagesService.getMessage(id)
|
||||
?: return@get call.respond(HttpStatusCode.NotFound)
|
||||
} catch (e: Throwable) {
|
||||
return@get call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to e.message)
|
||||
)
|
||||
}
|
||||
|
||||
call.respond(
|
||||
message.toDomain(
|
||||
requireNotNull(settings.deviceId),
|
||||
includeContent = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.inboxRoutes(context: Context) {
|
||||
post("export") {
|
||||
if (!requireScope(AuthScopes.MessagesExport)) return@post
|
||||
val request = call.receive<PostMessagesInboxExportRequest>().validate()
|
||||
try {
|
||||
receiverService.export(context, request.period, true)
|
||||
call.respond(HttpStatusCode.Accepted)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to export inbox: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageWithRecipients.toDomain(
|
||||
deviceId: String,
|
||||
includeContent: Boolean = false
|
||||
): me.capcom.smsgateway.modules.localserver.domain.messages.Message {
|
||||
return me.capcom.smsgateway.modules.localserver.domain.messages.Message(
|
||||
id = message.id,
|
||||
deviceId = deviceId,
|
||||
state = message.state,
|
||||
isHashed = false,
|
||||
isEncrypted = message.isEncrypted,
|
||||
textMessage = when (includeContent) {
|
||||
true -> message.textContent?.let {
|
||||
TextMessage(it.text)
|
||||
}
|
||||
|
||||
else -> null
|
||||
},
|
||||
dataMessage = when (includeContent) {
|
||||
true -> message.dataContent?.let {
|
||||
DataMessage(
|
||||
data = it.data,
|
||||
port = it.port.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
},
|
||||
hashedMessage = null,
|
||||
recipients = recipients.map {
|
||||
me.capcom.smsgateway.modules.localserver.domain.messages.Message.Recipient(
|
||||
it.phoneNumber,
|
||||
it.state,
|
||||
it.error
|
||||
)
|
||||
},
|
||||
states = states.associate {
|
||||
it.state to Date(it.updatedAt)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.patch
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.settings.SettingsService
|
||||
|
||||
class SettingsRoutes(
|
||||
private val settingsService: SettingsService
|
||||
) {
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
settingsRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.settingsRoutes() {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.SettingsRead)) return@get
|
||||
try {
|
||||
val settings = settingsService.getAll()
|
||||
call.respond(settings)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
mapOf("message" to "Failed to get settings: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
patch {
|
||||
if (!requireScope(AuthScopes.SettingsWrite)) return@patch
|
||||
try {
|
||||
val settings = call.receive<Map<String, *>>()
|
||||
|
||||
settingsService.update(settings)
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
settingsService.getAll()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("message" to "Invalid request: ${e.message}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package me.capcom.smsgateway.modules.localserver.routes
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import me.capcom.smsgateway.domain.EntitySource
|
||||
import me.capcom.smsgateway.modules.localserver.LocalServerSettings
|
||||
import me.capcom.smsgateway.modules.localserver.auth.AuthScopes
|
||||
import me.capcom.smsgateway.modules.localserver.auth.requireScope
|
||||
import me.capcom.smsgateway.modules.webhooks.WebHooksService
|
||||
import me.capcom.smsgateway.modules.webhooks.domain.WebHookDTO
|
||||
|
||||
class WebhooksRoutes(
|
||||
private val webHooksService: WebHooksService,
|
||||
private val localServerSettings: LocalServerSettings,
|
||||
) {
|
||||
fun register(routing: Route) {
|
||||
routing.apply {
|
||||
webhooksRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.webhooksRoutes() {
|
||||
get {
|
||||
if (!requireScope(AuthScopes.WebhooksList)) return@get
|
||||
call.respond(webHooksService.select(EntitySource.Local))
|
||||
}
|
||||
post {
|
||||
if (!requireScope(AuthScopes.WebhooksWrite)) return@post
|
||||
val webhook = call.receive<WebHookDTO>()
|
||||
if (webhook.deviceId != null && webhook.deviceId != localServerSettings.deviceId) {
|
||||
throw IllegalArgumentException(
|
||||
"Device ID mismatch"
|
||||
)
|
||||
}
|
||||
|
||||
val updated = webHooksService.replace(EntitySource.Local, webhook)
|
||||
|
||||
call.respond(HttpStatusCode.Created, updated)
|
||||
}
|
||||
delete("/{id}") {
|
||||
if (!requireScope(AuthScopes.WebhooksDelete)) return@delete
|
||||
val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest)
|
||||
webHooksService.delete(EntitySource.Local, id)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.capcom.smsgateway.modules.logs
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.GsonBuilder
|
||||
import me.capcom.smsgateway.extensions.configure
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntriesDao
|
||||
import me.capcom.smsgateway.modules.logs.db.LogEntry
|
||||
import me.capcom.smsgateway.modules.logs.workers.TruncateWorker
|
||||
|
||||
class LogsService(
|
||||
private val dao: LogEntriesDao,
|
||||
private val settings: LogsSettings,
|
||||
) {
|
||||
private val gson = GsonBuilder().configure().create()
|
||||
|
||||
fun start(context: Context) {
|
||||
TruncateWorker.start(context)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
TruncateWorker.stop(context)
|
||||
}
|
||||
|
||||
suspend fun select(
|
||||
from: Long? = null,
|
||||
to: Long? = null
|
||||
): List<LogEntry> {
|
||||
val from = from ?: 0
|
||||
val to = to ?: System.currentTimeMillis()
|
||||
|
||||
return dao.selectByPeriod(from, to)
|
||||
}
|
||||
|
||||
fun insert(
|
||||
priority: LogEntry.Priority,
|
||||
module: String,
|
||||
message: String,
|
||||
context: Any? = null
|
||||
) {
|
||||
try {
|
||||
dao.insert(
|
||||
LogEntry(
|
||||
priority,
|
||||
module,
|
||||
message,
|
||||
context = context?.let { gson.toJsonTree(it) }
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun truncate() {
|
||||
val lifetimeDays = settings.lifetimeDays ?: return
|
||||
val until = System.currentTimeMillis() - lifetimeDays * 86400000L
|
||||
|
||||
dao.truncate(until)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package me.capcom.smsgateway.modules.logs
|
||||
|
||||
import me.capcom.smsgateway.modules.settings.Exporter
|
||||
import me.capcom.smsgateway.modules.settings.Importer
|
||||
import me.capcom.smsgateway.modules.settings.KeyValueStorage
|
||||
import me.capcom.smsgateway.modules.settings.get
|
||||
|
||||
class LogsSettings(
|
||||
private val storage: KeyValueStorage,
|
||||
) : Exporter, Importer {
|
||||
val lifetimeDays: Int?
|
||||
get() = storage.get<Int?>(LIFETIME_DAYS)?.takeIf { it > 0 }
|
||||
|
||||
private var version: Int
|
||||
get() = storage.get<Int>(VERSION) ?: 0
|
||||
set(value) = storage.set(VERSION, value)
|
||||
|
||||
init {
|
||||
migrate()
|
||||
}
|
||||
|
||||
private fun migrate() {
|
||||
if (version == VERSION_CODE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (version < 1) {
|
||||
storage.set(LIFETIME_DAYS, "30")
|
||||
}
|
||||
|
||||
version = VERSION_CODE
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LIFETIME_DAYS = "lifetime_days"
|
||||
|
||||
private const val VERSION_CODE = 1
|
||||
private const val VERSION = "version"
|
||||
}
|
||||
|
||||
override fun export(): Map<String, *> {
|
||||
return mapOf(
|
||||
LIFETIME_DAYS to lifetimeDays,
|
||||
)
|
||||
}
|
||||
|
||||
override fun import(data: Map<String, *>): Boolean {
|
||||
return data.map {
|
||||
when (it.key) {
|
||||
LIFETIME_DAYS -> {
|
||||
val newValue = it.value?.toString()?.toFloat()?.toInt()?.takeIf { it > 0 }
|
||||
val changed = lifetimeDays != newValue
|
||||
|
||||
storage.set(it.key, newValue?.toString())
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}.any { it }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package me.capcom.smsgateway.modules.logs
|
||||
|
||||
import android.content.Intent
|
||||
import org.json.JSONObject
|
||||
|
||||
object LogsUtils {
|
||||
fun Intent.toLogContext(): Map<String, *> = mapOf(
|
||||
"action" to this.action,
|
||||
"data" to this.dataString,
|
||||
"extras" to JSONObject().apply {
|
||||
extras?.keySet()?.forEach { key -> this.putOpt(key, JSONObject.wrap(extras?.get(key))) }
|
||||
},
|
||||
)
|
||||
|
||||
fun Throwable.toLogContext(): Map<String, *> = mapOf(
|
||||
"message" to this.message,
|
||||
"stackTrace" to this.stackTrace.take(10).joinToString("\n"),
|
||||
"threadName" to Thread.currentThread().name,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package me.capcom.smsgateway.modules.logs
|
||||
|
||||
import me.capcom.smsgateway.modules.logs.repositories.LogsRepository
|
||||
import me.capcom.smsgateway.modules.logs.vm.LogsViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val logsModule = module {
|
||||
singleOf(::LogsRepository)
|
||||
singleOf(::LogsService)
|
||||
viewModelOf(::LogsViewModel)
|
||||
}
|
||||
|
||||
val NAME = "logs"
|
||||
@@ -0,0 +1,21 @@
|
||||
package me.capcom.smsgateway.modules.logs.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface LogEntriesDao {
|
||||
@Query("SELECT * FROM logs_entries WHERE createdAt BETWEEN :from and :to")
|
||||
suspend fun selectByPeriod(from: Long, to: Long): List<LogEntry>
|
||||
|
||||
@Query("SELECT * FROM logs_entries ORDER BY id DESC LIMIT 50")
|
||||
fun selectLast(): LiveData<List<LogEntry>>
|
||||
|
||||
@Insert
|
||||
fun insert(entry: LogEntry)
|
||||
|
||||
@Query("DELETE FROM logs_entries WHERE createdAt < :until")
|
||||
suspend fun truncate(until: Long)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package me.capcom.smsgateway.modules.logs.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.JsonElement
|
||||
|
||||
|
||||
@Entity(tableName = "logs_entries", indices = [androidx.room.Index(value = ["createdAt"])])
|
||||
data class LogEntry(
|
||||
val priority: Priority,
|
||||
val module: String,
|
||||
val message: String,
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val context: JsonElement? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
enum class Priority {
|
||||
DEBUG,
|
||||
INFO,
|
||||
WARN,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user