Update: 2026-08-07 19:01:00
This commit is contained in:
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* tools/env_keys.php — جرد مفاتيح البيئة وزراعة الناقص منها
|
||||||
|
* ─────────────────────────────────────────────────────────────
|
||||||
|
* الاستعمال:
|
||||||
|
* php env_keys.php audit # جرد فقط، لا يكتب شيئاً
|
||||||
|
* php env_keys.php seed # يزرع الناقص القابل للتوليد
|
||||||
|
* php env_keys.php seed --dry-run # يُظهر ما سيفعله بلا كتابة
|
||||||
|
*
|
||||||
|
* المفاتيح ليست صنفاً واحداً، والخلط بينها هو ما يحوّل «تدوير مفتاح»
|
||||||
|
* إلى فقدان بيانات لا رجعة فيه. أربعة أصناف:
|
||||||
|
*
|
||||||
|
* • generated — سرّ بيننا وبين أنفسنا. نولّده ونُدوّره متى شئنا.
|
||||||
|
* • session — تدويره يُخرج كل المستخدمين من حساباتهم. مقبول بوعي.
|
||||||
|
* • frozen — **لا يُدوَّر إطلاقاً**. بيانات مخزَّنة مشتقّة منه،
|
||||||
|
* وتغييره يجعلها غير قابلة للقراءة إلى الأبد.
|
||||||
|
* • external — من طرف ثالث. لا يمكن توليده، يُنسخ من لوحته.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = dirname(__DIR__, 2);
|
||||||
|
$envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
|
||||||
|
|
||||||
|
$command = $argv[1] ?? 'audit';
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// التصنيف
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** أسرار داخلية: طرفاها كلاهما لنا. توليدها وتدويرها آمنان. */
|
||||||
|
const GENERATED = [
|
||||||
|
'S2S_SHARED_KEY' => 32, // خادم ↔ خادم داخلي
|
||||||
|
'WEBHOOK_SECRET_KEY' => 32, // هاتف الرسائل ↔ الويبهوك
|
||||||
|
'INTERNAL_SOCKET_KEY' => 32, // الباك إند ↔ خوادم السوكيت
|
||||||
|
'BOT_SECRET_KEY' => 24, // بوت الأسعار
|
||||||
|
'FCM_INTERNAL_API_KEY' => 32,
|
||||||
|
'MIGRATION_ADMIN_KEY' => 24,
|
||||||
|
'PAYMENT_KEY' => 32,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تدويرها يبطل كل الجلسات القائمة: كل سائق وكل راكب سيُخرَج ويُطالَب
|
||||||
|
* بتسجيل دخول جديد. ليس عطلاً لكنه حدث تشغيلي — لا يُفعل وقت الذروة.
|
||||||
|
*/
|
||||||
|
const SESSION_SCOPED = [
|
||||||
|
'JWT_SECRET_KEY' => 64,
|
||||||
|
'SECRET_KEY_HMAC' => 32,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⛔ مجمّدة. هذه ليست «مفاتيح يمكن تغييرها من عندنا» رغم أنها ملكنا:
|
||||||
|
*
|
||||||
|
* ENC_KEY / ENCRYPTION_KEY_PATH
|
||||||
|
* تُشفَّر به أسماء الركّاب وهواتفهم وبرديهم ولوحات السيارات
|
||||||
|
* **المخزَّنة في القاعدة**. تغييره لا يعيد تشفير شيء — يجعل كل
|
||||||
|
* صف قديم رطانةً لا تُفكّ. لا استرجاع، ولا نسخة احتياطية تنفع
|
||||||
|
* لأن النسخة نفسها مشفّرة بالمفتاح القديم.
|
||||||
|
*
|
||||||
|
* BLIND_INDEX_PEPPER
|
||||||
|
* أعمدة `*_bidx` محسوبة به ومخزَّنة. تغييره يعني أن كل بحث
|
||||||
|
* بالهاتف أو البريد يفشل — لا يجد المستخدم حسابه، ويُنشأ له
|
||||||
|
* حساب مكرر عند أول تسجيل.
|
||||||
|
*
|
||||||
|
* TRACKING_SECRET_SALT / FP_PEPPER
|
||||||
|
* قيم مشتقّة منها مخزَّنة أيضاً.
|
||||||
|
*
|
||||||
|
* تدويرها ممكن نظرياً لكنه **ترحيل بيانات** لا تغيير إعداد: فكّ بالقديم
|
||||||
|
* وإعادة تشفير بالجديد على كل صف، بتوقّف مخطَّط ونسخة احتياطية أولاً.
|
||||||
|
*/
|
||||||
|
const FROZEN = [
|
||||||
|
'ENC_KEY', 'ENCRYPTION_KEY_PATH', 'ENCRYPTION_MODE',
|
||||||
|
'BLIND_INDEX_PEPPER', 'TRACKING_SECRET_SALT', 'FP_PEPPER',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** لا تُولَّد — تُنسخ من لوحة المزوّد. */
|
||||||
|
const EXTERNAL = [
|
||||||
|
'GEMINI_API_KEY', 'NABEH_API_KEY', 'NABEH_PASSWORD', 'NABEH_OTP_APP_KEY',
|
||||||
|
'ELEVENLABS_API_KEY', 'CREATOMATE_API_KEY', 'SOCIAL_BOT_TOKEN',
|
||||||
|
'MAIL_PASSWORD', 'SIRO_SMTP_PASSWORD', 'SMS_PASSWORD_EGYPT',
|
||||||
|
'VOICE_CALL_API_KEY', 'RASEEL_DRIVER_ACCESS_TOKEN',
|
||||||
|
'FIREBASE_SERVICE_ACCOUNT_PATH', 'SERVICE_ACCOUNT_FILE_PATH',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* كلمات مرور قواعد البيانات — صنف قائم بذاته.
|
||||||
|
*
|
||||||
|
* تغييرها في .env **لا يغيّر شيئاً في MySQL**. متغيّرات صورة mysql
|
||||||
|
* (ومنها MYSQL_ROOT_PASSWORD) تُقرأ مرة واحدة فقط عند تهيئة مجلّد
|
||||||
|
* البيانات الفارغ أول مرة. بعد ذلك كلمة المرور تعيش داخل جدول
|
||||||
|
* mysql.user لا في .env — فتعديل .env وحده يعني تطبيقاً يحمل كلمة
|
||||||
|
* لا يقبلها الخادم: انقطاع كامل بلا أي تغيير في القاعدة نفسها.
|
||||||
|
*
|
||||||
|
* الترتيب الصحيح: ALTER USER في MySQL أولاً، ثم .env، ثم إعادة تشغيل
|
||||||
|
* حاويات php (لا mysql، ولا إعادة بناء، ولا مساس بالبيانات إطلاقاً).
|
||||||
|
*/
|
||||||
|
const DB_CREDENTIALS = [
|
||||||
|
'DB_PRIMARY_PASS_V2', 'DB_PRIMARY_PASS', 'DB_TRACKING_PASS',
|
||||||
|
'DB_RIDE_PASS', 'DB_TRANSIT_PASS', 'DB_FOOD_PASS', 'DB_PAYMENT_PASS',
|
||||||
|
'MYSQL_ROOT_PASSWORD', 'REDIS_PASSWORD', 'REDIS_MAIN_PASSWORD',
|
||||||
|
'REDIS_LOCATION_PASSWORD', 'REDIS_AUTH', 'REDIS_MAIN_AUTH',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// القراءة
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
if (!is_readable($envFile)) {
|
||||||
|
fwrite(STDERR, "لا يمكن قراءة $envFile\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
||||||
|
$present = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/', $line, $m)) {
|
||||||
|
$present[$m[1]] = trim($m[2], "\"' ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** قيمة نائبة لا سرّاً: موجودة شكلاً وغائبة فعلاً. */
|
||||||
|
$isPlaceholder = static function (string $v): bool {
|
||||||
|
if ($v === '') return true;
|
||||||
|
return (bool) preg_match('/^(<?CHANGE_ME|changeme|your[_-]|xxx+|TODO|REPLACE)/i', $v);
|
||||||
|
};
|
||||||
|
|
||||||
|
echo "ملف البيئة: $envFile\n";
|
||||||
|
echo str_repeat('─', 62) . "\n";
|
||||||
|
|
||||||
|
$toSeed = [];
|
||||||
|
|
||||||
|
$report = static function (string $title, array $keys, array $present,
|
||||||
|
callable $isPlaceholder, ?array &$toSeed = null,
|
||||||
|
bool $seedable = false) {
|
||||||
|
echo "\n▸ $title\n";
|
||||||
|
foreach ($keys as $k => $v) {
|
||||||
|
$name = is_int($k) ? $v : $k;
|
||||||
|
$bytes = is_int($k) ? 0 : $v;
|
||||||
|
|
||||||
|
$exists = array_key_exists($name, $present);
|
||||||
|
$blank = $exists && $isPlaceholder($present[$name]);
|
||||||
|
|
||||||
|
$state = !$exists ? 'مفقود' : ($blank ? 'نائب' : 'مضبوط');
|
||||||
|
printf(" %-30s %s\n", $name, $state);
|
||||||
|
|
||||||
|
if ($seedable && ($state !== 'مضبوط') && $toSeed !== null) {
|
||||||
|
$toSeed[$name] = $bytes ?: 32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$report('أسرار داخلية (تُولَّد وتُدوَّر بحرية)', GENERATED, $present, $isPlaceholder, $toSeed, true);
|
||||||
|
$report('مرتبطة بالجلسات (تدويرها يُخرج الجميع)', SESSION_SCOPED, $present, $isPlaceholder, $toSeed, true);
|
||||||
|
$report('⛔ مجمّدة — لا تُدوَّر (بيانات مخزَّنة مشتقّة منها)', FROZEN, $present, $isPlaceholder);
|
||||||
|
$report('اعتماد قواعد البيانات (يُغيَّر في MySQL أولاً لا هنا)', DB_CREDENTIALS, $present, $isPlaceholder);
|
||||||
|
$report('طرف ثالث (تُنسخ من لوحة المزوّد)', EXTERNAL, $present, $isPlaceholder);
|
||||||
|
|
||||||
|
echo "\n" . str_repeat('─', 62) . "\n";
|
||||||
|
|
||||||
|
if ($command !== 'seed') {
|
||||||
|
echo empty($toSeed)
|
||||||
|
? "لا ينقص شيء قابل للتوليد.\n"
|
||||||
|
: 'قابل للزراعة: ' . count($toSeed) . " مفتاحاً. شغّل: php env_keys.php seed\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// الزراعة
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
if (empty($toSeed)) {
|
||||||
|
echo "لا ينقص شيء قابل للتوليد.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// لا نلمس مفتاحاً مضبوطاً أبداً: هذه الأداة تملأ الفراغ فقط. التدوير
|
||||||
|
// قرار واعٍ له تبعات (جلسات، خوادم أخرى تحمل نفس السرّ)، ولا يجوز أن
|
||||||
|
// يقع كأثر جانبي لتشغيل أداة جرد.
|
||||||
|
$additions = [];
|
||||||
|
foreach ($toSeed as $name => $bytes) {
|
||||||
|
$value = bin2hex(random_bytes($bytes));
|
||||||
|
$additions[$name] = $value;
|
||||||
|
echo "توليد $name (" . ($bytes * 2) . " حرفاً)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "\n[dry-run] لم يُكتب شيء.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// نسخة احتياطية قبل أي كتابة. ملف .env تالف يعني توقف كل شيء، وهو
|
||||||
|
// غير موجود في git بطبيعته فلا مصدر آخر لاستعادته.
|
||||||
|
$backup = $envFile . '.bak.' . date('Ymd_His');
|
||||||
|
if (!copy($envFile, $backup)) {
|
||||||
|
fwrite(STDERR, "تعذّرت النسخة الاحتياطية — أُلغيت العملية.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
@chmod($backup, 0600);
|
||||||
|
echo "\nنسخة احتياطية: $backup\n";
|
||||||
|
|
||||||
|
$out = rtrim(file_get_contents($envFile), "\n") . "\n";
|
||||||
|
|
||||||
|
// نستبدل السطر النائب في مكانه، ونُلحق المفقود في النهاية — الاستبدال
|
||||||
|
// في المكان يبقي التعليقات المجاورة مفيدة.
|
||||||
|
foreach ($additions as $name => $value) {
|
||||||
|
if (array_key_exists($name, $present)) {
|
||||||
|
$out = preg_replace(
|
||||||
|
'/^\s*' . preg_quote($name, '/') . '\s*=.*$/m',
|
||||||
|
"$name=$value",
|
||||||
|
$out,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
unset($additions[$name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($additions)) {
|
||||||
|
$out .= "\n# ── زُرعت تلقائياً بـ tools/env_keys.php في " . date('Y-m-d H:i') . " ──\n";
|
||||||
|
foreach ($additions as $name => $value) {
|
||||||
|
$out .= "$name=$value\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($envFile, $out) === false) {
|
||||||
|
fwrite(STDERR, "تعذّرت الكتابة. النسخة الاحتياطية سليمة: $backup\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
@chmod($envFile, 0600);
|
||||||
|
|
||||||
|
echo "\nتمّت الزراعة.\n";
|
||||||
|
echo "الخطوة التالية — أعد تشغيل حاويات php لتقرأ الجديد:\n";
|
||||||
|
echo " docker compose restart php php_food php_transit\n\n";
|
||||||
|
echo "تذكير: WEBHOOK_SECRET_KEY يجب أن يطابق RELAY_SECRET في\n";
|
||||||
|
echo "sms_relay/app/build.gradle.kts — وهو يحتاج إعادة بناء للتطبيق.\n";
|
||||||
@@ -681,4 +681,13 @@ class AppLink {
|
|||||||
|
|
||||||
// ================= Scheduled Rides =================
|
// ================= Scheduled Rides =================
|
||||||
static String get scheduledList => "$server/ride/scheduled/list.php";
|
static String get scheduledList => "$server/ride/scheduled/list.php";
|
||||||
|
|
||||||
|
// ================= Driver Insurance =================
|
||||||
|
// ملاحظة: addHealthInsuranceProvider/getHealthInsuranceProvider أعلاه هما
|
||||||
|
// المسار القديم على جدول driver_health_assurance. هذه هي نقاط الخطط
|
||||||
|
// والأقساط الجديدة، وتعمل على جداول insurance_* المنفصلة.
|
||||||
|
static String get insurancePlans => "$server/driver_assurance/plans.php";
|
||||||
|
static String get insuranceSubscribe =>
|
||||||
|
"$server/driver_assurance/subscribe.php";
|
||||||
|
static String get insuranceCancel => "$server/driver_assurance/cancel.php";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import '../../views/widgets/error_snakbar.dart';
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
|
||||||
|
/// حالة تأمين السائق: الوثيقة النشطة، ودفتر الأقساط، والخطط المتاحة.
|
||||||
|
///
|
||||||
|
/// الخطط والحالة نداءان منفصلان في الخادم (plans.php و get.php)، لكنهما
|
||||||
|
/// شاشة واحدة عند السائق — فنجلبهما معاً ونعرض ما اكتمل. فشل أحدهما لا
|
||||||
|
/// يُفرغ الشاشة من الآخر.
|
||||||
|
class DriverInsuranceController extends GetxController {
|
||||||
|
final CRUD _crud = CRUD();
|
||||||
|
|
||||||
|
final isLoading = false.obs;
|
||||||
|
final isSubmitting = false.obs;
|
||||||
|
|
||||||
|
/// الوثيقة النشطة، أو null إن لم يكن مشتركاً.
|
||||||
|
final policy = Rxn<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
/// ملخّص الدفتر: المستحق والمسدَّد.
|
||||||
|
final ledger = <String, dynamic>{}.obs;
|
||||||
|
|
||||||
|
/// آخر القيود — السائق الذي يُخصم من أرباحه يجب أن يرى مقابل ماذا.
|
||||||
|
final entries = <Map<String, dynamic>>[].obs;
|
||||||
|
|
||||||
|
/// كل الخطط، لا المؤهَّل لها فقط: إخفاء ما لا يستحقه بعد يجعل التأمين
|
||||||
|
/// مجهولاً عنده، وإظهاره مع «ينقصك كذا رحلة» يحوّله إلى هدف.
|
||||||
|
final plans = <Map<String, dynamic>>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshAll() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
await Future.wait([_fetchStatus(), _fetchPlans()]);
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchStatus() async {
|
||||||
|
try {
|
||||||
|
final data = _extract(await _crud.post(
|
||||||
|
link: AppLink.getHealthInsuranceProvider,
|
||||||
|
payload: const {},
|
||||||
|
));
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
policy.value = data['policy'] is Map
|
||||||
|
? Map<String, dynamic>.from(data['policy'] as Map)
|
||||||
|
: null;
|
||||||
|
ledger.assignAll(data['ledger'] is Map
|
||||||
|
? Map<String, dynamic>.from(data['ledger'] as Map)
|
||||||
|
: <String, dynamic>{});
|
||||||
|
entries.assignAll(((data['entries'] as List?) ?? [])
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList());
|
||||||
|
} catch (_) {
|
||||||
|
mySnackbarError('Failed to load insurance status'.tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPlans() async {
|
||||||
|
try {
|
||||||
|
final data = _extract(await _crud.post(
|
||||||
|
link: AppLink.insurancePlans,
|
||||||
|
payload: const {},
|
||||||
|
));
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
plans.assignAll(((data['plans'] as List?) ?? [])
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList());
|
||||||
|
} catch (_) {
|
||||||
|
mySnackbarError('Failed to load insurance plans'.tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يشترك في خطة. الخادم يعيد فحص الأهلية بنفسه — الشاشة قد تكون
|
||||||
|
/// مفتوحة منذ ساعة، فما عرضناه ليس حجة.
|
||||||
|
Future<bool> subscribe(int planId) async {
|
||||||
|
if (isSubmitting.value) return false;
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.insuranceSubscribe,
|
||||||
|
payload: {'plan_id': planId.toString()},
|
||||||
|
);
|
||||||
|
final decoded = res is String ? jsonDecode(res) : res;
|
||||||
|
|
||||||
|
if (decoded is Map && decoded['status'] == 'success') {
|
||||||
|
await refreshAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// رسالة الخادم تحمل سبب الرفض بالتفصيل (ما ينقص السائق تحديداً)،
|
||||||
|
// فنعرضها كما هي بدل رسالة عامة لا تدلّه على شيء.
|
||||||
|
mySnackbarError(_errorOf(decoded) ?? 'Subscription failed'.tr);
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
mySnackbarError('Subscription failed'.tr);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> cancel({String reason = ''}) async {
|
||||||
|
if (isSubmitting.value) return false;
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.insuranceCancel,
|
||||||
|
payload: {'reason': reason},
|
||||||
|
);
|
||||||
|
final decoded = res is String ? jsonDecode(res) : res;
|
||||||
|
|
||||||
|
if (decoded is Map && decoded['status'] == 'success') {
|
||||||
|
await refreshAll();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
mySnackbarError(_errorOf(decoded) ?? 'Cancellation failed'.tr);
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
mySnackbarError('Cancellation failed'.tr);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasPolicy => policy.value != null;
|
||||||
|
|
||||||
|
double get pendingTotal =>
|
||||||
|
double.tryParse('${ledger['pending_total'] ?? 0}') ?? 0;
|
||||||
|
|
||||||
|
String get currency => '${ledger['currency'] ?? policy.value?['currency'] ?? ''}';
|
||||||
|
|
||||||
|
Map<String, dynamic>? _extract(dynamic res) {
|
||||||
|
final decoded = res is String ? jsonDecode(res) : res;
|
||||||
|
if (decoded is! Map || decoded['status'] != 'success') return null;
|
||||||
|
final data = decoded['message'] ?? decoded['data'];
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _errorOf(dynamic decoded) {
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
final m = decoded['message'] ?? decoded['error'];
|
||||||
|
return m is String && m.isNotEmpty ? m : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -949,5 +949,24 @@
|
|||||||
"Destination": "وجهة الوصول",
|
"Destination": "وجهة الوصول",
|
||||||
"Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة",
|
"Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة",
|
||||||
"Passenger offline — call them": "راكب بلا إنترنت — اتصل به",
|
"Passenger offline — call them": "راكب بلا إنترنت — اتصل به",
|
||||||
"No internet — call": "بلا إنترنت — اتصل"
|
"No internet — call": "بلا إنترنت — اتصل",
|
||||||
|
"Insurance": "التأمين",
|
||||||
|
"Premium": "القسط",
|
||||||
|
"Monthly": "شهري",
|
||||||
|
"Daily": "يومي",
|
||||||
|
"Started on": "بدأت في",
|
||||||
|
"Policy number": "رقم الوثيقة",
|
||||||
|
"Premium ledger": "دفتر الأقساط",
|
||||||
|
"Outstanding": "المستحق",
|
||||||
|
"Settled": "المسدَّد",
|
||||||
|
"Cancel policy": "إلغاء الوثيقة",
|
||||||
|
"Subscribe": "اشترك",
|
||||||
|
"Not eligible yet": "غير مؤهَّل بعد",
|
||||||
|
"rides": "رحلة",
|
||||||
|
"No insurance plans available yet": "لا توجد خطط تأمين متاحة بعد",
|
||||||
|
"Failed to load insurance status": "تعذّر تحميل حالة التأمين",
|
||||||
|
"Failed to load insurance plans": "تعذّر تحميل خطط التأمين",
|
||||||
|
"Subscription failed": "تعذّر الاشتراك",
|
||||||
|
"Cancellation failed": "تعذّر الإلغاء",
|
||||||
|
"Cancelling stops future premiums. Outstanding dues remain payable.": "الإلغاء يوقف الأقساط القادمة، ويبقى المستحق السابق واجب السداد."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -907,5 +907,24 @@
|
|||||||
"Destination": "Destination",
|
"Destination": "Destination",
|
||||||
"Failed to fetch scheduled rides": "Failed to fetch scheduled rides",
|
"Failed to fetch scheduled rides": "Failed to fetch scheduled rides",
|
||||||
"Passenger offline — call them": "Passenger offline — call them",
|
"Passenger offline — call them": "Passenger offline — call them",
|
||||||
"No internet — call": "No internet — call"
|
"No internet — call": "No internet — call",
|
||||||
|
"Insurance": "Insurance",
|
||||||
|
"Premium": "Premium",
|
||||||
|
"Monthly": "Monthly",
|
||||||
|
"Daily": "Daily",
|
||||||
|
"Started on": "Started on",
|
||||||
|
"Policy number": "Policy number",
|
||||||
|
"Premium ledger": "Premium ledger",
|
||||||
|
"Outstanding": "Outstanding",
|
||||||
|
"Settled": "Settled",
|
||||||
|
"Cancel policy": "Cancel policy",
|
||||||
|
"Subscribe": "Subscribe",
|
||||||
|
"Not eligible yet": "Not eligible yet",
|
||||||
|
"rides": "rides",
|
||||||
|
"No insurance plans available yet": "No insurance plans available yet",
|
||||||
|
"Failed to load insurance status": "Failed to load insurance status",
|
||||||
|
"Failed to load insurance plans": "Failed to load insurance plans",
|
||||||
|
"Subscription failed": "Subscription failed",
|
||||||
|
"Cancellation failed": "Cancellation failed",
|
||||||
|
"Cancelling stops future premiums. Outstanding dues remain payable.": "Cancelling stops future premiums. Outstanding dues remain payable."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '../../../../constant/colors.dart';
|
|||||||
import '../About Us/video_page.dart';
|
import '../About Us/video_page.dart';
|
||||||
import '../../../food_delivery/food_delivery_home_page.dart';
|
import '../../../food_delivery/food_delivery_home_page.dart';
|
||||||
import '../../journal/driver_scheduled_rides_page.dart';
|
import '../../journal/driver_scheduled_rides_page.dart';
|
||||||
|
import '../../journal/driver_insurance_page.dart';
|
||||||
|
|
||||||
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
|
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
|
||||||
class DrawerItem {
|
class DrawerItem {
|
||||||
@@ -87,6 +88,11 @@ class AppDrawer extends StatelessWidget {
|
|||||||
icon: Icons.edit_calendar_rounded,
|
icon: Icons.edit_calendar_rounded,
|
||||||
color: Colors.orangeAccent,
|
color: Colors.orangeAccent,
|
||||||
onTap: () => Get.to(() => const DriverScheduledRidesPage())),
|
onTap: () => Get.to(() => const DriverScheduledRidesPage())),
|
||||||
|
DrawerItem(
|
||||||
|
title: 'Insurance'.tr,
|
||||||
|
icon: Icons.health_and_safety_rounded,
|
||||||
|
color: Colors.indigo,
|
||||||
|
onTap: () => Get.to(() => const DriverInsurancePage())),
|
||||||
DrawerItem(
|
DrawerItem(
|
||||||
title: 'Leaderboard'.tr,
|
title: 'Leaderboard'.tr,
|
||||||
icon: Icons.leaderboard_rounded,
|
icon: Icons.leaderboard_rounded,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../../constant/colors.dart';
|
||||||
|
import '../../../controller/insurance/driver_insurance_controller.dart';
|
||||||
|
|
||||||
|
/// شاشة التأمين: الوثيقة النشطة ودفترها، أو الخطط المتاحة.
|
||||||
|
///
|
||||||
|
/// الحالتان لا تظهران معاً — من له وثيقة يرى وثيقته، ومن لا وثيقة له
|
||||||
|
/// يرى الخطط. عرض الاثنين كان سيوحي أن الاشتراك في ثانية ممكن، وهو
|
||||||
|
/// ليس كذلك: تبديل الخطة يمرّ بإلغاء صريح ليبقى في السجل أثر.
|
||||||
|
class DriverInsurancePage extends StatelessWidget {
|
||||||
|
const DriverInsurancePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = Get.put(DriverInsuranceController());
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text('Insurance'.tr), centerTitle: true),
|
||||||
|
body: Obx(() {
|
||||||
|
if (c.isLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: c.refreshAll,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: c.hasPolicy ? _policyView(context, c) : _plansView(context, c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── الوثيقة النشطة ─────────────────────────────────────────
|
||||||
|
List<Widget> _policyView(BuildContext context, DriverInsuranceController c) {
|
||||||
|
final p = c.policy.value!;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Card(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.verified_user, color: Colors.green),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${p['plan'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${p['provider'] ?? ''}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600)),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_row('Premium'.tr,
|
||||||
|
'${p['premium']} ${p['currency']} / '
|
||||||
|
'${p['billing_cycle'] == 'monthly' ? 'Monthly'.tr : 'Daily'.tr}'),
|
||||||
|
_row('Started on'.tr, '${p['started_at'] ?? '—'}'),
|
||||||
|
if ((p['policy_number'] ?? '').toString().isNotEmpty)
|
||||||
|
_row('Policy number'.tr, '${p['policy_number']}'),
|
||||||
|
if ((p['coverage'] ?? '').toString().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('${p['coverage']}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ── دفتر الأقساط ──
|
||||||
|
// يظهر في نفس الشاشة لا في أخرى: الخصم الذي لا يرى السائق مقابله
|
||||||
|
// مفاجأة تُفقد الثقة، مهما كان مشروحاً في مكان آخر.
|
||||||
|
Card(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Premium ledger'.tr,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_row('Outstanding'.tr,
|
||||||
|
'${c.pendingTotal.toStringAsFixed(2)} ${c.currency}'),
|
||||||
|
_row('Settled'.tr,
|
||||||
|
'${ledgerNum(c.ledger['settled_total'])} ${c.currency}'),
|
||||||
|
if (c.entries.isNotEmpty) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
...c.entries.take(10).map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${e['charge_date']}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
Text('${e['amount']} ${e['currency']}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: e['status'] == 'settled'
|
||||||
|
? Colors.green
|
||||||
|
: Colors.orange.shade800,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: c.isSubmitting.value ? null : () => _confirmCancel(context, c),
|
||||||
|
icon: const Icon(Icons.cancel_outlined, color: Colors.red),
|
||||||
|
label: Text('Cancel policy'.tr,
|
||||||
|
style: const TextStyle(color: Colors.red)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── الخطط المتاحة ──────────────────────────────────────────
|
||||||
|
List<Widget> _plansView(BuildContext context, DriverInsuranceController c) {
|
||||||
|
if (c.plans.isEmpty) {
|
||||||
|
return [
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
Icon(Icons.health_and_safety_outlined,
|
||||||
|
size: 80, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('No insurance plans available yet'.tr,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 18, color: Colors.grey)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.plans.map((plan) {
|
||||||
|
final eligible = plan['eligible'] == true;
|
||||||
|
final missing = (plan['missing'] as List?) ?? [];
|
||||||
|
final progress = (plan['progress_rides'] ?? 0) as num;
|
||||||
|
final required = (plan['required_rides'] ?? 0) as num;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('${plan['name'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
Text('${plan['provider'] ?? ''}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${plan['premium']} ${plan['currency']} / '
|
||||||
|
'${plan['billing_cycle'] == 'monthly' ? 'Monthly'.tr : 'Daily'.tr}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColor.primaryColor),
|
||||||
|
),
|
||||||
|
if ((plan['coverage'] ?? '').toString().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('${plan['coverage']}',
|
||||||
|
style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
|
||||||
|
// شريط التقدّم نحو الشرط — الرقم وحده لا يحرّك أحداً،
|
||||||
|
// ورؤية القرب من الهدف تفعل.
|
||||||
|
if (!eligible && required > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: required == 0 ? 0 : (progress / required).clamp(0, 1),
|
||||||
|
backgroundColor: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('$progress / $required ${'rides'.tr}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12, color: Colors.grey.shade600)),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!eligible && missing.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...missing.map((m) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_outline,
|
||||||
|
size: 14, color: Colors.orange),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text('$m',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.orange.shade900)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: (!eligible || c.isSubmitting.value)
|
||||||
|
? null
|
||||||
|
: () => c.subscribe(plan['id'] as int),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColor.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size.fromHeight(44),
|
||||||
|
),
|
||||||
|
child: Text(eligible ? 'Subscribe'.tr : 'Not eligible yet'.tr),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmCancel(BuildContext context, DriverInsuranceController c) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text('Cancel policy'.tr),
|
||||||
|
// نصرّح بأن المستحق السابق يبقى: الإلغاء يوقف القادم ولا يمحو
|
||||||
|
// أياماً استُفيد من تغطيتها فعلاً.
|
||||||
|
content: Text(c.pendingTotal > 0
|
||||||
|
? '${'Cancelling stops future premiums. Outstanding dues remain payable.'.tr}\n\n'
|
||||||
|
'${'Outstanding'.tr}: ${c.pendingTotal.toStringAsFixed(2)} ${c.currency}'
|
||||||
|
: 'Cancelling stops future premiums. Outstanding dues remain payable.'.tr),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text('Back'.tr),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
c.cancel();
|
||||||
|
},
|
||||||
|
child: Text('Confirm'.tr,
|
||||||
|
style: const TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String ledgerNum(dynamic v) =>
|
||||||
|
(double.tryParse('${v ?? 0}') ?? 0).toStringAsFixed(2);
|
||||||
|
|
||||||
|
Widget _row(String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
Flexible(
|
||||||
|
child: Text(value,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file — يطابق android_bot ليبقى الإصداران متسقين.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.13.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.18.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
|
activityCompose = "1.8.0"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
#Sun Jun 21 02:56:41 EET 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "sms_relay"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user