Update: 2026-08-07 19:01:00

This commit is contained in:
Hamza-Ayed
2026-08-07 19:01:00 +03:00
parent cdd36d4777
commit 0db59647eb
31 changed files with 1974 additions and 2 deletions
+233
View File
@@ -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";
+9
View File
@@ -681,4 +681,13 @@ class AppLink {
// ================= Scheduled Rides =================
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;
}
}
+20 -1
View File
@@ -949,5 +949,24 @@
"Destination": "وجهة الوصول",
"Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة",
"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.": "الإلغاء يوقف الأقساط القادمة، ويبقى المستحق السابق واجب السداد."
}
+20 -1
View File
@@ -907,5 +907,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"
"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 '../../../food_delivery/food_delivery_home_page.dart';
import '../../journal/driver_scheduled_rides_page.dart';
import '../../journal/driver_insurance_page.dart';
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
class DrawerItem {
@@ -87,6 +88,11 @@ class AppDrawer extends StatelessWidget {
icon: Icons.edit_calendar_rounded,
color: Colors.orangeAccent,
onTap: () => Get.to(() => const DriverScheduledRidesPage())),
DrawerItem(
title: 'Insurance'.tr,
icon: Icons.health_and_safety_rounded,
color: Colors.indigo,
onTap: () => Get.to(() => const DriverInsurancePage())),
DrawerItem(
title: 'Leaderboard'.tr,
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)),
),
],
),
);
}
+64
View File
@@ -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)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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>
+6
View File
@@ -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
}
+23
View File
@@ -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
+32
View File
@@ -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" }
Binary file not shown.
+6
View File
@@ -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
Vendored Executable
+185
View File
@@ -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" "$@"
+89
View File
@@ -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
+23
View File
@@ -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")