commit 1f97307814d8ac842b7dba6e4ce183a0cae0922b Author: 李志强 <357099073@qq.com> Date: Wed Mar 25 16:56:45 2026 +0800 first commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..8fb5154 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1510 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..639900d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..456b18c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sms.iml b/.idea/sms.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/sms.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/SMS/.gitignore b/SMS/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/SMS/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/SMS/.idea/AndroidProjectSystem.xml b/SMS/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/SMS/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/SMS/.idea/compiler.xml b/SMS/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/SMS/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/SMS/.idea/deploymentTargetSelector.xml b/SMS/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..3d3845d --- /dev/null +++ b/SMS/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/SMS/.idea/gradle.xml b/SMS/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/SMS/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/SMS/.idea/inspectionProfiles/Project_Default.xml b/SMS/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/SMS/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/SMS/.idea/migrations.xml b/SMS/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/SMS/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/SMS/.idea/misc.xml b/SMS/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/SMS/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/SMS/.idea/runConfigurations.xml b/SMS/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/SMS/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/SMS/README.md b/SMS/README.md new file mode 100644 index 0000000..1098a1c --- /dev/null +++ b/SMS/README.md @@ -0,0 +1,33 @@ +# android-gateway(安卓短信网关端) + +## 使用方式(Android Studio 导入) + +由于仓库里无法直接提供 `gradle-wrapper.jar` 这类二进制文件,你需要在 Android Studio 里先创建一个项目,再把本目录里的源码/配置拷贝进去。 + +1. Android Studio:创建新项目 + - Template:`Empty Activity` + - Kotlin:勾选 + - Package name:`com.yunzer.sms`(建议与本项目一致) +2. 把本目录 `app/src/main/...` 下的内容复制到你新建项目对应位置(覆盖同名文件) +3. 把本目录根目录下的 `build.gradle` / `settings.gradle` / `app/build.gradle` 复制到对应位置(如需) + +## 配置说明(运行前) + +首次进入 App 会让你填写: + +- `backendUrl`:例如 `http://192.168.1.10:3000` +- `apiKey`:后端 `.env` 里的 `SMS_GATEWAY_API_KEY` +- (不再需要 `deviceId`:由后端根据 `apiKey` 自动归属任务/短信) + +保存后 App 会: + +- 启动前台服务轮询后端下发发送任务 +- 注册系统短信接收广播,上报短信内容并解析验证码 + +你也可以点击 `检测心跳` 按钮,用于手动验证 App 与后端的网络/鉴权通断。 + +## 注意事项 + +- 需要运行时授权:`接收短信`、`发送短信` +- 安卓 13+ 还需要授权:`通知`(用于前台服务) +- 该端用于你自己的合法业务场景,避免违规批量操作 diff --git a/SMS/app/.gitignore b/SMS/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/SMS/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/SMS/app/build.gradle.kts b/SMS/app/build.gradle.kts new file mode 100644 index 0000000..a03452d --- /dev/null +++ b/SMS/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/SMS/app/proguard-rules.pro b/SMS/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/SMS/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/SMS/app/release/app-release.apk b/SMS/app/release/app-release.apk new file mode 100644 index 0000000..fa97615 Binary files /dev/null and b/SMS/app/release/app-release.apk differ diff --git a/SMS/app/release/baselineProfiles/0/app-release.dm b/SMS/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..9c7e08e Binary files /dev/null and b/SMS/app/release/baselineProfiles/0/app-release.dm differ diff --git a/SMS/app/release/baselineProfiles/1/app-release.dm b/SMS/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..1b4f5a1 Binary files /dev/null and b/SMS/app/release/baselineProfiles/1/app-release.dm differ diff --git a/SMS/app/release/output-metadata.json b/SMS/app/release/output-metadata.json new file mode 100644 index 0000000..1991e96 --- /dev/null +++ b/SMS/app/release/output-metadata.json @@ -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 +} \ No newline at end of file diff --git a/SMS/app/src/androidTest/java/com/yunzer/sms/ExampleInstrumentedTest.kt b/SMS/app/src/androidTest/java/com/yunzer/sms/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f655ebf --- /dev/null +++ b/SMS/app/src/androidTest/java/com/yunzer/sms/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/SMS/app/src/main/AndroidManifest.xml b/SMS/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ad56d6d --- /dev/null +++ b/SMS/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SMS/app/src/main/java/com/yunzer/sms/ApiClient.kt b/SMS/app/src/main/java/com/yunzer/sms/ApiClient.kt new file mode 100644 index 0000000..1f8a0e5 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/ApiClient.kt @@ -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 { + 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(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") + } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/BootReceiver.kt b/SMS/app/src/main/java/com/yunzer/sms/BootReceiver.kt new file mode 100644 index 0000000..6d5fd39 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/BootReceiver.kt @@ -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) + } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/ConfigStore.kt b/SMS/app/src/main/java/com/yunzer/sms/ConfigStore.kt new file mode 100644 index 0000000..efc1f7d --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/ConfigStore.kt @@ -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() + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/CryptoUtils.kt b/SMS/app/src/main/java/com/yunzer/sms/CryptoUtils.kt new file mode 100644 index 0000000..8756c1f --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/CryptoUtils.kt @@ -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) } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/MainActivity.kt b/SMS/app/src/main/java/com/yunzer/sms/MainActivity.kt new file mode 100644 index 0000000..69d76f1 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/MainActivity.kt @@ -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 { + 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) } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/SmsGatewayLogger.kt b/SMS/app/src/main/java/com/yunzer/sms/SmsGatewayLogger.kt new file mode 100644 index 0000000..b2cec8e --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/SmsGatewayLogger.kt @@ -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) + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/SmsParser.kt b/SMS/app/src/main/java/com/yunzer/sms/SmsParser.kt new file mode 100644 index 0000000..420eab3 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/SmsParser.kt @@ -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") + } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/SmsReceiver.kt b/SMS/app/src/main/java/com/yunzer/sms/SmsReceiver.kt new file mode 100644 index 0000000..e1ae971 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/SmsReceiver.kt @@ -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() + } + } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/SmsSender.kt b/SMS/app/src/main/java/com/yunzer/sms/SmsSender.kt new file mode 100644 index 0000000..9edf64d --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/SmsSender.kt @@ -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 { + 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) + } + } +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/TaskSyncService.kt b/SMS/app/src/main/java/com/yunzer/sms/TaskSyncService.kt new file mode 100644 index 0000000..1ab6ecd --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/TaskSyncService.kt @@ -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 +} + diff --git a/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Color.kt b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Color.kt new file mode 100644 index 0000000..71b4962 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Theme.kt b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Theme.kt new file mode 100644 index 0000000..17c5402 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Type.kt b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Type.kt new file mode 100644 index 0000000..d775546 --- /dev/null +++ b/SMS/app/src/main/java/com/yunzer/sms/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/SMS/app/src/main/res/drawable/ic_launcher_background.xml b/SMS/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/SMS/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SMS/app/src/main/res/drawable/ic_launcher_foreground.xml b/SMS/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/SMS/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/SMS/app/src/main/res/drawable/ic_stat_notify.xml b/SMS/app/src/main/res/drawable/ic_stat_notify.xml new file mode 100644 index 0000000..2107b8e --- /dev/null +++ b/SMS/app/src/main/res/drawable/ic_stat_notify.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/SMS/app/src/main/res/layout/activity_main.xml b/SMS/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e9c847b --- /dev/null +++ b/SMS/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,94 @@ + + + + + + + + + +