Update: 2026-08-07 19:01:00
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.siro.relay"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.siro.relay"
|
||||
// 24 لا أعلى: الهاتف الذي يحمل الشريحة قد يكون جهازاً رخيصاً
|
||||
// قديماً مركوناً في المكتب، لا هاتفاً حديثاً.
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// نفس مفتاح WEBHOOK_SECRET_KEY في backend — الويبهوك ونقطة
|
||||
// السحب كلاهما يتحقق منه. غيّره هنا وهناك معاً أو يتوقف كل شيء.
|
||||
buildConfigField("String", "RELAY_SECRET", "\"CHANGE_ME_WEBHOOK_SECRET\"")
|
||||
buildConfigField("String", "BACKEND_HOST", "\"https://jordan-siro.intaleqapp.com\"")
|
||||
|
||||
// معرّف الجهاز: يظهر في `claimed_by` بجدول sms_outbox. لو شغّلت
|
||||
// هاتفين بشريحتين، غيّره في الثاني لتعرف أيّهما أرسل ماذا.
|
||||
buildConfigField("String", "DEVICE_ID", "\"phone-1\"")
|
||||
}
|
||||
|
||||
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
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- استقبال الرسائل الواردة وإرسال الردود عبر شريحة الهاتف.
|
||||
هاتان الصلاحيتان هما كل سبب وجود التطبيق. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- خدمة أمامية: بدونها يقتل النظام السحب الدوري خلال دقائق حين
|
||||
تُطفأ الشاشة، فتتراكم الردود في الطابور ولا تصل. -->
|
||||
<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" />
|
||||
|
||||
<!-- الإقلاع مع الجهاز: انقطاع كهرباء يعيد تشغيل الهاتف، ولا أحد
|
||||
بجانبه ليفتح التطبيق يدوياً. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SiroRelay"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<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"
|
||||
android:permission="android.permission.BROADCAST_SMS">
|
||||
<intent-filter android:priority="999">
|
||||
<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=".RelayService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.siro.relay
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* عميل HTTP بسيط لنقطتي الباك إند.
|
||||
*
|
||||
* HttpURLConnection لا OkHttp عمداً: التطبيق يقوم بنداءين اثنين لا أكثر،
|
||||
* وإضافة مكتبة شبكة كاملة من أجلهما تضخّم حزمةً ستعمل على هاتف قديم
|
||||
* مركون بلا صيانة. أقل ما يمكن كسره هو ما لم يُضَف أصلاً.
|
||||
*/
|
||||
object Api {
|
||||
|
||||
private const val TAG = "SiroRelay/Api"
|
||||
|
||||
private val WEBHOOK_URL = "${BuildConfig.BACKEND_HOST}/webhook_sms/webhook.php"
|
||||
private val OUTBOX_URL = "${BuildConfig.BACKEND_HOST}/sms/outbox_pull.php"
|
||||
|
||||
/** رسالة صادرة كما وصلت من الطابور. */
|
||||
data class Outgoing(val id: Int, val recipient: String, val body: String)
|
||||
|
||||
/**
|
||||
* يرفع رسالة واردة إلى الويبهوك.
|
||||
*
|
||||
* @return true عند النجاح — المتصل يعيد المحاولة عند الفشل.
|
||||
*/
|
||||
fun pushIncoming(sender: String, message: String): Boolean {
|
||||
val payload = JSONObject().apply {
|
||||
put("sender", sender)
|
||||
put("message", message)
|
||||
}
|
||||
|
||||
return try {
|
||||
val (code, _) = post(WEBHOOK_URL, payload.toString())
|
||||
if (code != 200) Log.w(TAG, "الويبهوك رد بـ $code")
|
||||
code == 200
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "تعذّر رفع الوارد: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** يسحب دفعة رسائل للإرسال. قائمة فارغة تعني لا شيء ينتظر. */
|
||||
fun pullOutgoing(): List<Outgoing> {
|
||||
val payload = JSONObject().apply {
|
||||
put("action", "pull")
|
||||
put("device", BuildConfig.DEVICE_ID)
|
||||
}
|
||||
|
||||
return try {
|
||||
val (code, body) = post(OUTBOX_URL, payload.toString())
|
||||
if (code != 200) {
|
||||
Log.w(TAG, "السحب رد بـ $code")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val arr = JSONObject(body).optJSONArray("messages") ?: return emptyList()
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val to = o.optString("recipient")
|
||||
val text = o.optString("body")
|
||||
// صف ناقص لا يُرسل ولا يُسقط الدفعة: نتخطاه ويبقى في
|
||||
// الطابور حتى تنتهي مهلة الحجز فيُحرَّر ويُراجَع.
|
||||
if (to.isBlank() || text.isBlank()) null
|
||||
else Outgoing(o.optInt("id"), to, text)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "تعذّر السحب: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يبلّغ الخادم بنتائج الإرسال.
|
||||
*
|
||||
* التبليغ ليس تفصيلاً: الرسالة تبقى محجوزة في الخادم حتى يصله، وبلا
|
||||
* تبليغ تُحرَّر بعد المهلة وتُرسل ثانيةً — أي رسالتان للراكب نفسه.
|
||||
*/
|
||||
fun ack(results: List<Triple<Int, Boolean, String?>>): Boolean {
|
||||
if (results.isEmpty()) return true
|
||||
|
||||
val arr = JSONArray()
|
||||
results.forEach { (id, sent, error) ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", id)
|
||||
put("sent", sent)
|
||||
if (error != null) put("error", error)
|
||||
})
|
||||
}
|
||||
|
||||
val payload = JSONObject().apply {
|
||||
put("action", "ack")
|
||||
put("device", BuildConfig.DEVICE_ID)
|
||||
put("results", arr)
|
||||
}
|
||||
|
||||
return try {
|
||||
val (code, _) = post(OUTBOX_URL, payload.toString())
|
||||
code == 200
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "تعذّر التبليغ: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(url: String, json: String): Pair<Int, String> {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("Authorization", "Bearer ${BuildConfig.RELAY_SECRET}")
|
||||
}
|
||||
|
||||
return try {
|
||||
conn.outputStream.use { it.write(json.toByteArray(Charsets.UTF_8)) }
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: ""
|
||||
code to body
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.siro.relay
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* يعيد تشغيل الخدمة بعد إقلاع الجهاز.
|
||||
*
|
||||
* ليس ترفاً: الهاتف الحامل للشريحة سيبقى مركوناً في المكتب، وانقطاع
|
||||
* كهرباء واحد يعيد تشغيله. بلا هذا المستقبِل تبقى القناة ميتة حتى
|
||||
* ينتبه أحد ويفتح التطبيق يدوياً — وقد لا ينتبه أحد لأيام.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
RelayService.start(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.siro.relay
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* شاشة واحدة: تطلب الصلاحيات وتشغّل الخدمة.
|
||||
*
|
||||
* لا لوحة إعدادات ولا سجل رسائل عمداً. هذا الجهاز يعمل بلا مشرف،
|
||||
* وكل زر إضافي فيه احتمال أن يعبث به أحد فيوقف القناة. الإعدادات
|
||||
* كلها في BuildConfig — تتغيّر ببناء جديد لا بلمسة على الشاشة.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val required = buildList {
|
||||
add(Manifest.permission.RECEIVE_SMS)
|
||||
add(Manifest.permission.READ_SMS)
|
||||
add(Manifest.permission.SEND_SMS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
private var granted by mutableStateOf(false)
|
||||
|
||||
private val requestPermissions = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { result ->
|
||||
// الإشعارات وحدها ليست حاسمة — رفضها يخفي الإشعار الدائم فقط.
|
||||
// أمّا صلاحيات الرسائل فبدونها التطبيق بلا وظيفة.
|
||||
granted = result.filterKeys { it != Manifest.permission.POST_NOTIFICATIONS }
|
||||
.all { it.value }
|
||||
if (granted) RelayService.start(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
granted = required.all {
|
||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (granted) RelayService.start(this)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = getString(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = getString(
|
||||
if (granted) R.string.status_running else R.string.status_needs_permission
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (!granted) {
|
||||
Button(onClick = { requestPermissions.launch(required.toTypedArray()) }) {
|
||||
Text(getString(R.string.grant_permissions))
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = BuildConfig.BACKEND_HOST,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(
|
||||
text = "${getString(R.string.device_label)}: ${BuildConfig.DEVICE_ID}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.siro.relay
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* خدمة أمامية تسحب الردود من الخادم وترسلها عبر شريحة الهاتف.
|
||||
*
|
||||
* لماذا سحب لا دفع: الهاتف خلف شبكة الجوّال بلا عنوان ثابت ولا منفذ
|
||||
* مفتوح، فلا سبيل للخادم أن يبادره. والسحب يجعل انقطاع الهاتف حالة
|
||||
* طبيعية — تتراكم الرسائل في الطابور وتُرسل حين يعود، بلا فقدان.
|
||||
*
|
||||
* ولماذا أمامية: النظام يقتل أي عمل دوري في الخلفية خلال دقائق بعد
|
||||
* إطفاء الشاشة. الإشعار الدائم ثمن مقبول مقابل قناة يُفترض ألّا تنام.
|
||||
*/
|
||||
class RelayService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SiroRelay/Service"
|
||||
private const val CHANNEL_ID = "siro_relay"
|
||||
private const val NOTIF_ID = 4210
|
||||
|
||||
private const val ACTION_POLL_NOW = "com.siro.relay.POLL_NOW"
|
||||
|
||||
/** دورة السحب الاعتيادية. */
|
||||
private const val POLL_INTERVAL_MS = 5_000L
|
||||
|
||||
/** تباعد بين رسالتين متتاليتين — الشريحة لا تحب الدفعات السريعة. */
|
||||
private const val SEND_GAP_MS = 1_500L
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, RelayService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** يوقظ دورة سحب فورية بدل انتظار الموقّت. */
|
||||
fun requestImmediatePoll(context: Context) {
|
||||
start(context)
|
||||
wakeUp.set(true)
|
||||
}
|
||||
|
||||
private val wakeUp = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@Volatile private var running = false
|
||||
private var worker: Thread? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
|
||||
// استدعاء ثانٍ للخدمة العاملة لا يبدأ خيطاً ثانياً: خيطان يسحبان
|
||||
// معاً يعني رسائل تُرسل مرتين.
|
||||
if (!running) {
|
||||
running = true
|
||||
worker = thread(name = "relay-loop") { loop() }
|
||||
}
|
||||
|
||||
// START_STICKY: لو قتلها النظام تحت ضغط ذاكرة، يعيدها. القناة
|
||||
// يجب أن تعود وحدها — لا أحد بجانب الهاتف ليفتح التطبيق.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
running = false
|
||||
worker?.interrupt()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun loop() {
|
||||
while (running) {
|
||||
try {
|
||||
pumpOnce()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "خطأ في الدورة: ${e.message}")
|
||||
}
|
||||
|
||||
// نوم مجزّأ ليستجيب للإيقاظ الفوري بدل انتظار الدورة كاملة.
|
||||
var slept = 0L
|
||||
while (running && slept < POLL_INTERVAL_MS) {
|
||||
if (wakeUp.compareAndSet(true, false)) break
|
||||
try {
|
||||
Thread.sleep(250)
|
||||
} catch (e: InterruptedException) {
|
||||
return
|
||||
}
|
||||
slept += 250
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pumpOnce() {
|
||||
val batch = Api.pullOutgoing()
|
||||
if (batch.isEmpty()) return
|
||||
|
||||
Log.i(TAG, "سُحبت ${batch.size} رسالة")
|
||||
val results = mutableListOf<Triple<Int, Boolean, String?>>()
|
||||
|
||||
for (msg in batch) {
|
||||
val error = sendSms(msg.recipient, msg.body)
|
||||
results.add(Triple(msg.id, error == null, error))
|
||||
|
||||
if (msg !== batch.last()) {
|
||||
try {
|
||||
Thread.sleep(SEND_GAP_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// نبلّغ عن كل ما أُرسل فعلاً حتى لو انقطعت الدفعة في منتصفها،
|
||||
// وإلا أُعيد إرسال ما نجح منها بعد انتهاء مهلة الحجز.
|
||||
Api.ack(results)
|
||||
}
|
||||
|
||||
/** @return رسالة الخطأ، أو null عند النجاح. */
|
||||
private fun sendSms(to: String, body: String): String? {
|
||||
return try {
|
||||
val manager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
getSystemService(SmsManager::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
SmsManager.getDefault()
|
||||
} ?: return "SmsManager unavailable"
|
||||
|
||||
// الرسالة العربية تتجاوز حد الجزء الواحد بسرعة (٧٠ حرفاً في
|
||||
// ترميز UCS-2 لا ١٦٠). التقسيم إجباري لا اختياري.
|
||||
val parts = manager.divideMessage(body)
|
||||
if (parts.size > 1) {
|
||||
manager.sendMultipartTextMessage(to, null, parts, null, null)
|
||||
} else {
|
||||
manager.sendTextMessage(to, null, body, null, null)
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "تعذّر الإرسال إلى $to: ${e.message}")
|
||||
e.message ?: "send failed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.channel_name),
|
||||
// أدنى أهمية: إشعار دائم لا ينبغي أن يصدر صوتاً ولا أن
|
||||
// يقفز فوق الشاشة كل مرة.
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val tap = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notif_title))
|
||||
.setContentText(getString(R.string.notif_text))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(tap)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.siro.relay
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* يلتقط كل رسالة واردة ويرفعها إلى الويبهوك.
|
||||
*
|
||||
* لا فرز هنا إطلاقاً: التطبيق يرفع كل شيء، والخادم يقرّر أهي طلب رحلة
|
||||
* أم إشعار دفع أم شيء ثالث. نقل الفرز إلى الهاتف كان سيعني أن كل تعديل
|
||||
* على قواعد الفهم يستوجب تحديث تطبيق على جهاز لا أحد يقترب منه.
|
||||
*/
|
||||
class SmsReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
|
||||
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
// الرسالة الطويلة تصل مقسّمة إلى أجزاء متتالية من نفس المرسِل.
|
||||
// رفع كل جزء وحده كان سينتج طلبات مبتورة بلا معنى — فنلمّها
|
||||
// بالمرسِل ونرفع النص كاملاً.
|
||||
val bySender = messages
|
||||
.groupBy { it.displayOriginatingAddress ?: it.originatingAddress ?: "" }
|
||||
.mapValues { (_, parts) -> parts.joinToString("") { it.displayMessageBody ?: "" } }
|
||||
|
||||
// onReceive يعمل على الخيط الرئيسي وله ثوانٍ معدودة قبل أن يقتله
|
||||
// النظام. النداء الشبكي في خيط منفصل، ونتحمّل أن يُفقد لو مات
|
||||
// السياق — المرسِل سيعيد الإرسال حين لا يصله رد.
|
||||
bySender.forEach { (sender, body) ->
|
||||
if (sender.isBlank() || body.isBlank()) return@forEach
|
||||
|
||||
thread(name = "relay-in") {
|
||||
val ok = Api.pushIncoming(sender, body)
|
||||
Log.i("SiroRelay", if (ok) "رُفعت رسالة من $sender" else "فشل رفع رسالة من $sender")
|
||||
|
||||
// نوقظ خدمة السحب فوراً بدل انتظار الدورة التالية:
|
||||
// الرد على الراكب جاهز في الطابور خلال أجزاء من الثانية
|
||||
// بعد الرفع، وانتظاره دورةً كاملة تأخير بلا سبب.
|
||||
if (ok) RelayService.requestImmediatePoll(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<resources>
|
||||
<string name="app_name">وسيط سيرو</string>
|
||||
<string name="channel_name">قناة الرسائل</string>
|
||||
<string name="notif_title">وسيط سيرو يعمل</string>
|
||||
<string name="notif_text">يستقبل طلبات الركاب ويرسل الردود</string>
|
||||
<string name="status_running">القناة تعمل. اترك الهاتف موصولاً بالكهرباء والشبكة.</string>
|
||||
<string name="status_needs_permission">يحتاج التطبيق صلاحيات الرسائل ليعمل.</string>
|
||||
<string name="grant_permissions">منح الصلاحيات</string>
|
||||
<string name="device_label">معرّف الجهاز</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.SiroRelay" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
HTTPS فقط، بلا أي استثناء.
|
||||
|
||||
نسخة android_bot تسمح بنص صريح لعدة نطاقات. هنا لا يجوز: ما يمرّ في
|
||||
هذه القناة أرقام هواتف ركّاب وعناوين انطلاق ووجهات، ومفتاح Bearer
|
||||
يمنح حامله حق ضخّ الرسائل. الهاتف سيعمل على شبكة جوّال عامة سنوات
|
||||
بلا مراجعة — الافتراض الآمن هو الافتراض الوحيد المقبول.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user