first commit

This commit is contained in:
2026-03-25 16:56:45 +08:00
commit 1f97307814
1076 changed files with 380917 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+62
View File
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.yunzer.sms"
compileSdk = 36
defaultConfig {
applicationId = "com.yunzer.sms"
minSdk = 30
targetSdk = 36
versionCode = 2
versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
// 显式引入 core 模块,避免运行时缺失 CoreComponentFactory
implementation("androidx.core:core:1.13.1")
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Binary file not shown.
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.yunzer.sms",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 2,
"versionName": "1.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 30
}
@@ -0,0 +1,24 @@
package com.yunzer.sms
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.yunzer.sms", appContext.packageName)
}
}
+51
View File
@@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 接收系统短信广播 -->
<receiver
android:name=".SmsReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- 开机后拉起前台轮询服务 -->
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".TaskSyncService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,134 @@
package com.yunzer.sms
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
data class OutboundTask(
val taskId: String,
val phone: String,
val content: String,
val retryCount: Int = 0,
val lastError: String? = null
)
class ApiClient(
private val config: GatewayConfig
) {
private val connectTimeoutMs = 8000
private val readTimeoutMs = 15000
private fun openConnection(urlStr: String, method: String): HttpURLConnection {
return (URL(urlStr).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = connectTimeoutMs
readTimeout = readTimeoutMs
setRequestProperty("X-Api-Key", config.apiKey)
doInput = true
}
}
private fun readBody(conn: HttpURLConnection): String {
val stream = try {
if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
} catch (_: Exception) {
null
}
if (stream == null) return ""
return BufferedReader(InputStreamReader(stream)).use { it.readText() }
}
private fun writeJsonBody(conn: HttpURLConnection, payload: JSONObject) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { writer ->
writer.write(payload.toString())
writer.flush()
}
}
@Throws(IOException::class)
fun uploadInbound(
sender: String,
content: String,
receivedAtMs: Long,
parsedCode: String?,
parseStatus: String,
rawPduHash: String?
): String {
val url = "${config.backendUrl}/api/v1/sms/inbound"
val payload = JSONObject()
.put("sender", sender)
.put("content", content)
.put("receivedAt", receivedAtMs)
.put("parsedCode", parsedCode ?: JSONObject.NULL)
.put("parseStatus", parseStatus)
.put("rawPduHash", rawPduHash ?: JSONObject.NULL)
val conn = openConnection(url, "POST")
writeJsonBody(conn, payload)
val code = conn.responseCode
val text = readBody(conn)
if (code !in 200..299) {
throw IOException("uploadInbound failed: HTTP $code, body=$text")
}
val obj = JSONObject(text)
return obj.optString("ackId", "")
}
@Throws(IOException::class)
fun fetchTasks(limit: Int = 5): List<OutboundTask> {
val url = "${config.backendUrl}/api/v1/device/tasks?limit=${limit}"
val conn = openConnection(url, "GET")
val code = conn.responseCode
val text = readBody(conn)
if (code !in 200..299) {
throw IOException("fetchTasks failed: HTTP $code, body=$text")
}
val obj = JSONObject(text)
val tasks: JSONArray = obj.optJSONArray("tasks") ?: JSONArray()
val list = ArrayList<OutboundTask>(tasks.length())
for (i in 0 until tasks.length()) {
val t = tasks.getJSONObject(i)
list.add(
OutboundTask(
taskId = t.getString("taskId"),
phone = t.getString("phone"),
content = t.getString("content"),
retryCount = t.optInt("retryCount", 0),
lastError = if (t.isNull("lastError")) null else t.optString("lastError")
)
)
}
return list
}
@Throws(IOException::class)
fun reportOutboundResult(taskId: String, status: String, error: String? = null) {
val url = "${config.backendUrl}/api/v1/sms/outbound/result"
val payload = JSONObject()
.put("taskId", taskId)
.put("status", status)
.put("error", error ?: JSONObject.NULL)
val conn = openConnection(url, "POST")
writeJsonBody(conn, payload)
val code = conn.responseCode
if (code !in 200..299) {
val text = readBody(conn)
throw IOException("reportOutboundResult failed: HTTP $code, body=$text")
}
}
}
@@ -0,0 +1,27 @@
package com.yunzer.sms
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED) return
try {
val i = Intent(context, TaskSyncService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(context, i)
} else {
context.startService(i)
}
} catch (e: Exception) {
Log.e("BootReceiver", "failed to start service on boot: ${e.message}", e)
}
}
}
@@ -0,0 +1,32 @@
package com.yunzer.sms
import android.content.Context
import android.content.SharedPreferences
data class GatewayConfig(
val backendUrl: String,
val apiKey: String
)
class ConfigStore(private val context: Context) {
private val sp: SharedPreferences =
context.getSharedPreferences("smssgw_config", Context.MODE_PRIVATE)
fun load(): GatewayConfig? {
val backendUrl = sp.getString("backendUrl", null)?.trim().orEmpty()
val apiKey = sp.getString("apiKey", null)?.trim().orEmpty()
if (backendUrl.isEmpty() || apiKey.isEmpty()) return null
return GatewayConfig(
backendUrl = backendUrl.removeSuffix("/"),
apiKey = apiKey
)
}
fun save(config: GatewayConfig) {
sp.edit()
.putString("backendUrl", config.backendUrl)
.putString("apiKey", config.apiKey)
.apply()
}
}
@@ -0,0 +1,13 @@
package com.yunzer.sms
import java.security.MessageDigest
object CryptoUtils {
fun sha256Hex(input: String): String {
val bytes = input.toByteArray(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(bytes)
return hash.joinToString("") { b -> "%02x".format(b) }
}
}
@@ -0,0 +1,224 @@
package com.yunzer.sms
import android.Manifest
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MainActivity : ComponentActivity() {
private lateinit var etBackendUrl: EditText
private lateinit var etApiKey: EditText
private lateinit var btnSave: Button
private lateinit var etTestPhone: EditText
private lateinit var btnSmsTest: Button
private lateinit var btnClearLog: Button
private lateinit var tvStatus: TextView
private lateinit var tvLog: TextView
private lateinit var svLog: ScrollView
private val logReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
if (intent == null) return
val msg = intent.getStringExtra(SmsGatewayLogger.EXTRA_MESSAGE) ?: return
val level = intent.getStringExtra(SmsGatewayLogger.EXTRA_LEVEL) ?: "INFO"
val line = buildLogLine(level, msg)
runOnUiThread { appendLogLine(line) }
}
}
private val configStore by lazy { ConfigStore(this) }
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
// 权限申请后可能有两类后续动作:
// 1)启动网关
// 2)如果用户在申请前点过短信测试,则继续发送测试
if (pendingSmsTestPhone != null) {
val p = pendingSmsTestPhone!!
pendingSmsTestPhone = null
sendTestSms(p)
return@registerForActivityResult
}
maybeStartGateway()
}
private var pendingSmsTestPhone: String? = null
private val testSmsContent =
"【云泽网】您好,这是一条测试短信,用于验证短信收发是否正常,请勿回复,谢谢!"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
etBackendUrl = findViewById(R.id.etBackendUrl)
etApiKey = findViewById(R.id.etApiKey)
btnSave = findViewById(R.id.btnSave)
etTestPhone = findViewById(R.id.etTestPhone)
btnSmsTest = findViewById(R.id.btnSmsTest)
btnClearLog = findViewById(R.id.btnClearLog)
tvStatus = findViewById(R.id.tvStatus)
tvLog = findViewById(R.id.tvLog)
svLog = findViewById(R.id.svLog)
configStore.load()?.let { cfg ->
etBackendUrl.setText(cfg.backendUrl)
etApiKey.setText(cfg.apiKey)
tvStatus.text = getString(R.string.config_saved)
tvLog.text = buildLogLine("INFO", "应用已配置,等待启动网关")
} ?: run {
tvStatus.text = getString(R.string.status_missing)
tvLog.text = buildLogLine("INFO", "尚未配置:请填写后端地址/ApiKey")
}
btnSave.setOnClickListener {
val backendUrl = etBackendUrl.text.toString().trim()
val apiKey = etApiKey.text.toString().trim()
if (!backendUrl.startsWith("http")) {
Toast.makeText(this, getString(R.string.config_invalid), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (apiKey.isEmpty()) {
Toast.makeText(this, getString(R.string.config_invalid), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
configStore.save(GatewayConfig(backendUrl = backendUrl, apiKey = apiKey))
tvStatus.text = getString(R.string.config_saved)
SmsGatewayLogger.post(this, "INFO", "配置已保存:backend=$backendUrl")
maybeStartGateway()
}
btnSmsTest.setOnClickListener {
val phoneRaw = etTestPhone.text.toString().trim()
val phone = normalizePhone(phoneRaw)
if (phone.isEmpty() || !phone.startsWith("+")) {
Toast.makeText(this, getString(R.string.sms_test_invalid_phone), Toast.LENGTH_SHORT)
.show()
SmsGatewayLogger.post(this, "WARN", "短信测试参数不合法 phone='$phoneRaw'")
return@setOnClickListener
}
// 若未授权,则申请权限后在回调里继续发送
val need = missingPermissions().filter { it == Manifest.permission.SEND_SMS }
if (need.isNotEmpty()) {
pendingSmsTestPhone = phone
Toast.makeText(this, "需要短信发送权限,正在申请…", Toast.LENGTH_SHORT).show()
SmsGatewayLogger.post(this, "INFO", "短信测试前缺少 SEND_SMS 权限,准备申请")
permissionLauncher.launch(need.toTypedArray())
return@setOnClickListener
}
sendTestSms(phone)
}
btnClearLog.setOnClickListener {
tvLog.text = ""
SmsGatewayLogger.post(this, "INFO", "日志已清空")
}
ensurePermissionsAndMaybeStart()
}
override fun onStart() {
super.onStart()
registerReceiver(logReceiver, IntentFilter(SmsGatewayLogger.ACTION_LOG))
}
override fun onStop() {
super.onStop()
unregisterReceiver(logReceiver)
}
private fun ensurePermissionsAndMaybeStart() {
val missing = missingPermissions()
if (missing.isNotEmpty()) {
Toast.makeText(this, getString(R.string.permission_tip), Toast.LENGTH_SHORT).show()
SmsGatewayLogger.post(this, "WARN", "缺少权限:$missing")
permissionLauncher.launch(missing.toTypedArray())
} else {
maybeStartGateway()
}
}
private fun maybeStartGateway() {
if (!hasAllRequiredPermissions()) return
val cfg = configStore.load() ?: return
SmsGatewayLogger.post(this, "INFO", "准备启动网关(仅使用 apiKey 鉴权)")
tvStatus.text = getString(R.string.config_saved)
val intent = Intent(this, TaskSyncService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(this, intent)
} else {
startService(intent)
}
}
private fun missingPermissions(): List<String> {
val need = mutableListOf(
Manifest.permission.RECEIVE_SMS,
Manifest.permission.SEND_SMS
)
if (Build.VERSION.SDK_INT >= 33) {
need.add(Manifest.permission.POST_NOTIFICATIONS)
}
return need.filter { perm ->
ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED
}
}
private fun hasAllRequiredPermissions(): Boolean {
return missingPermissions().isEmpty()
}
private fun normalizePhone(phone: String): String {
// 简单归一化:去空格/破折号
return phone.replace(" ", "").replace("-", "")
}
private fun sendTestSms(phone: String) {
SmsGatewayLogger.post(this, "INFO", "开始发送短信测试 phone=$phone")
val result = SmsSender.sendSms(this, phone, testSmsContent)
if (result.isSuccess) {
SmsGatewayLogger.post(this, "INFO", getString(R.string.sms_test_success))
tvStatus.text = "短信测试:成功"
} else {
val err = result.exceptionOrNull()?.message ?: "unknown_error"
SmsGatewayLogger.post(this, "ERROR", "${getString(R.string.sms_test_failed)} err=$err")
tvStatus.text = "短信测试:失败"
}
}
private fun buildLogLine(level: String, msg: String): String {
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
return "$ts [$level] $msg\n"
}
private fun appendLogLine(line: String) {
val existing = tvLog.text?.toString().orEmpty()
val combined = existing + line
val lines = combined.split("\n")
tvLog.text = if (lines.size > 220) {
val tail = lines.takeLast(220).joinToString("\n").trimEnd()
tail + "\n"
} else {
combined
}
svLog.post { svLog.fullScroll(View.FOCUS_DOWN) }
}
}
@@ -0,0 +1,18 @@
package com.yunzer.sms
import android.content.Context
import android.content.Intent
object SmsGatewayLogger {
const val ACTION_LOG = "com.yunzer.sms.ACTION_LOG"
const val EXTRA_LEVEL = "level"
const val EXTRA_MESSAGE = "message"
fun post(context: Context, level: String, message: String) {
val i = Intent(ACTION_LOG)
.putExtra(EXTRA_LEVEL, level)
.putExtra(EXTRA_MESSAGE, message)
context.sendBroadcast(i)
}
}
@@ -0,0 +1,34 @@
package com.yunzer.sms
data class ParsedCode(
val code: String?,
val status: String // matched/unmatched/error
)
object SmsParser {
private val regexByKeyword = Regex("""验证码[:\s]*([0-9]{4,8})""")
private val regex4 = Regex("""\b([0-9]{4})\b""")
private val regex6 = Regex("""\b([0-9]{6})\b""")
private val regex8 = Regex("""\b([0-9]{8})\b""")
fun parse(content: String): ParsedCode {
return try {
val c1 = regexByKeyword.find(content)?.groupValues?.getOrNull(1)
if (!c1.isNullOrEmpty()) return ParsedCode(c1, "matched")
val c2 = regex6.find(content)?.groupValues?.getOrNull(1)
if (!c2.isNullOrEmpty()) return ParsedCode(c2, "matched")
val c3 = regex8.find(content)?.groupValues?.getOrNull(1)
if (!c3.isNullOrEmpty()) return ParsedCode(c3, "matched")
val c4 = regex4.find(content)?.groupValues?.getOrNull(1)
if (!c4.isNullOrEmpty()) return ParsedCode(c4, "matched")
ParsedCode(code = null, status = "unmatched")
} catch (_: Exception) {
ParsedCode(code = null, status = "error")
}
}
}
@@ -0,0 +1,68 @@
package com.yunzer.sms
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val config = ConfigStore(context).load()
if (config == null) {
pendingResult.finish()
return@launch
}
// 使用系统工具方法解析短信,避免直接处理 PDU 的兼容/弃用问题
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
if (messages.isEmpty()) {
pendingResult.finish()
return@launch
}
val sender = messages.firstOrNull()?.originatingAddress.orEmpty()
val content = buildString {
messages.forEach { msg ->
append(msg.displayMessageBody)
append("\n")
}
}.trim()
val receivedAtMs = System.currentTimeMillis()
val parsed = SmsParser.parse(content)
val rawPduHash = CryptoUtils.sha256Hex("$sender|$content|$receivedAtMs")
val preview = if (content.length > 80) content.substring(0, 80) + "..." else content
SmsGatewayLogger.post(context, "INFO", "收到短信 from=$sender content=$preview")
SmsGatewayLogger.post(
context,
"INFO",
"解析结果 code=${parsed.code ?: "null"} status=${parsed.status}"
)
val ackId = ApiClient(config).uploadInbound(
sender = sender,
content = content,
receivedAtMs = receivedAtMs,
parsedCode = parsed.code,
parseStatus = parsed.status,
rawPduHash = rawPduHash
)
SmsGatewayLogger.post(context, "INFO", "入站上报成功 ackId=$ackId")
} catch (e: Exception) {
Log.e("SmsReceiver", "upload inbound sms failed: ${e.message}", e)
SmsGatewayLogger.post(context, "ERROR", "入站上报失败:${e.message}")
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,28 @@
package com.yunzer.sms
import android.content.Context
import android.os.Build
import android.telephony.SmsManager
object SmsSender {
fun sendSms(context: Context, phone: String, content: String): Result<Unit> {
return try {
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(SmsManager::class.java)
} else {
@Suppress("DEPRECATION")
SmsManager.getDefault()
}
val parts = smsManager.divideMessage(content)
if (parts.size <= 1) {
smsManager.sendTextMessage(phone, null, content, null, null)
} else {
smsManager.sendMultipartTextMessage(phone, null, parts, null, null)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,164 @@
package com.yunzer.sms
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.IBinder
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
class TaskSyncService : Service() {
companion object {
private const val TAG = "TaskSyncService"
private const val CHANNEL_ID = "smssgw_channel"
private const val NOTIFICATION_ID = 1001
private const val POLL_INTERVAL_MS = 10_000L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var loopJob: kotlinx.coroutines.Job? = null
private var loopCount = 0
override fun onCreate() {
super.onCreate()
SmsGatewayLogger.post(this, "INFO", "TaskSyncService onCreate,启动前台服务并进入轮询")
ensureNotificationChannel()
startForeground(NOTIFICATION_ID, buildForegroundNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (loopJob == null) {
loopJob = scope.launch {
runLoop()
}
}
return START_STICKY
}
private suspend fun runLoop() {
while (scope.isActive) {
loopCount += 1
try {
val config = ConfigStore(this).load()
if (config == null) {
SmsGatewayLogger.post(this, "WARN", "未配置:等待中")
delay(min(POLL_INTERVAL_MS * 3, 60_000L))
continue
}
val hasSendPerm = checkSelfPermission(Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
if (!hasSendPerm) {
SmsGatewayLogger.post(this, "WARN", "缺少 SEND_SMS 权限,等待授权")
delay(min(POLL_INTERVAL_MS * 3, 60_000L))
continue
}
SmsGatewayLogger.post(this, "INFO", "轮询任务中(第${loopCount}轮)...")
val api = ApiClient(config)
val tasks = api.fetchTasks(limit = 5)
SmsGatewayLogger.post(this, "INFO", "拉取到任务数量:${tasks.size}")
for (task in tasks) {
try {
SmsGatewayLogger.post(
this,
"INFO",
"发送短信 taskId=${task.taskId} phone=${task.phone}"
)
val result = SmsSender.sendSms(this, task.phone, task.content)
if (result.isSuccess) {
api.reportOutboundResult(task.taskId, status = "success")
SmsGatewayLogger.post(this, "INFO", "短信发送成功 taskId=${task.taskId}")
} else {
val errMsg = result.exceptionOrNull()?.message ?: "send_sms_failed"
api.reportOutboundResult(task.taskId, status = "failed", error = errMsg)
SmsGatewayLogger.post(
this,
"ERROR",
"短信发送失败 taskId=${task.taskId} err=$errMsg"
)
}
} catch (e: Exception) {
Log.e(TAG, "send/report task failed: ${e.message}", e)
SmsGatewayLogger.post(this, "ERROR", "任务执行失败 taskId=${task.taskId} err=${e.message}")
try {
api.reportOutboundResult(
task.taskId,
status = "failed",
error = e.message ?: "error"
)
} catch (_: Exception) {
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "sync loop error: ${e.message}", e)
SmsGatewayLogger.post(this, "ERROR", "轮询异常:${e.message}")
}
delay(POLL_INTERVAL_MS)
}
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.channel_description)
}
nm.createNotificationChannel(channel)
}
}
private fun buildForegroundNotification(): Notification {
// 直接使用系统 Notification.Builder(避免部分系统缺失 NotificationCompat 行为)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
builder
.setContentTitle(getString(R.string.noti_small))
.setContentText(getString(R.string.service_running))
.setSmallIcon(R.drawable.ic_stat_notify)
.setOngoing(true)
return builder.build()
}
override fun onDestroy() {
loopJob?.cancel()
scope.cancel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder? = null
}
@@ -0,0 +1,11 @@
package com.yunzer.sms.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package com.yunzer.sms.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun SMSTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.yunzer.sms.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF9800"
android:pathData="M12,22c1.1,0 2-0.9 2-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-0.83-0.67-1.5-1.5-1.5S10.5,3.17 10.5,4v0.68C7.63,5.36 6,7.92 6,11v5l-1.5,1.5V19h15v-1.5L18,16z"/>
</vector>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/status_ready"
android:textSize="16sp"
android:paddingBottom="12dp" />
<EditText
android:id="@+id/etBackendUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/backend_url"
android:inputType="textUri" />
<EditText
android:id="@+id/etApiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/api_key"
android:inputType="textPassword"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/save"
android:layout_marginTop="16dp" />
<EditText
android:id="@+id/etTestPhone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/sms_test_phone_hint"
android:inputType="phone"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnSmsTest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sms_test"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/btnClearLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/clear_log"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/status_missing"
android:layout_marginTop="12dp" />
<TextView
android:id="@+id/tvLogLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/log_label"
android:layout_marginTop="12dp"
android:textStyle="bold"
android:textSize="14sp" />
<ScrollView
android:id="@+id/svLog"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<TextView
android:id="@+id/tvLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="12sp"
android:scrollbars="vertical"
android:overScrollMode="always" />
</ScrollView>
</LinearLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/colorPrimary" />
<foreground android:drawable="@drawable/ic_stat_notify" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#1976D2</color>
<color name="colorPrimaryDark">#0D47A1</color>
<color name="colorAccent">#FF9800</color>
<color name="colorBackground">#FFFFFF</color>
</resources>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SmsGateway</string>
<string name="backend_url">后端地址</string>
<string name="api_key">ApiKey</string>
<string name="save">保存</string>
<string name="clear_log">清空日志</string>
<string name="log_label">运行日志</string>
<string name="sms_test_phone_hint">例如:+8613712345678</string>
<string name="sms_test">短信测试</string>
<string name="sms_test_invalid_phone">手机号不能为空且建议以+开头,如:+8613xxxxxx</string>
<string name="sms_test_sending">开始发送测试短信…</string>
<string name="sms_test_success">短信测试发送成功</string>
<string name="sms_test_failed">短信测试发送失败</string>
<string name="permission_tip">请授予短信接收/发送权限</string>
<string name="config_saved">配置已保存并启动网关</string>
<string name="config_invalid">请填写后端地址、ApiKey</string>
<string name="status_ready">状态:就绪</string>
<string name="status_missing">状态:未配置</string>
<string name="channel_name">短信网关</string>
<string name="channel_description">用于前台服务通知</string>
<string name="service_running">短信任务同步运行中</string>
<string name="noti_small">短信网关通知</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.DeviceDefault.NoActionBar" />
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SMS" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.yunzer.sms
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}