بدء مكالمة هاتفية من تطبيق Flutter أمر سهل: تفتح رابط tel: وتطبيق الهاتف في النظام يقوم بالباقي. أما أن يكون تطبيقك هو تطبيق الهاتف نفسه فمهمة أخرى. في Android، التطبيق الذي يختاره المستخدم كتطبيق الهاتف الافتراضي (default Phone app) هو الوحيد الذي يرى المكالمات الحية ويستطيع الرد عليها أو وضعها في الانتظار أو دمجها. هو الذي يعرض شاشة المكالمة الواردة، ويستطيع حجب المتصلين غير المرغوب فيهم، ويقرأ سجل المكالمات ويعدّله.
android_telecom إضافة (plugin) لـ Flutter لهذه المهمة. تستعمل الواجهات الرسمية التي يمنحها Android لتطبيق الهاتف الافتراضي: RoleManager.ROLE_DIALER وInCallService وCallScreeningService ومزوّد CallLog.
ماذا تقدّم
الدور (role). الدالتان isDefaultDialer() وrequestDefaultDialer() تفحصان الدور وتطلبانه. الطلب يستعمل RoleManager في API 29 وما بعده، وTelecomManager.ACTION_CHANGE_DEFAULT_DIALER في الإصدارات الأقدم. والدالتان isCallScreeningRoleHeld() وrequestCallScreeningRole() تقومان بالشيء نفسه لدور فحص المكالمات (call screening).
المكالمات الحية. calls هو Stream<List<CallSnapshot>> يغذّيه الـ InCallService الخاص بالإضافة. كل لقطة (snapshot) فيها معرّف، ورقم، وحالة، واتجاه، ووقت الاتصال، والإمكانات (hold، mute، merge، swap)، ومعلومات المكالمة الجماعية، وسبب الانقطاع. والـ stream المسمّى audioState يعطي مسار الصوت وحالة كتم الميكروفون.
التحكم. answer وreject(message:) وdisconnect وhold وunhold وmute وsetSpeaker وsetAudioRoute (سماعة الأذن، مكبّر الصوت، Bluetooth، سماعة سلكية، streaming) وplayDtmf وmerge وswap، ثم placeCall(number, phoneAccount:) عبر TelecomManager.placeCall.
الحجب (screening). قائمة حظر مع قواعد للمتصلين المجهولين وأصحاب الأرقام المخفية، مع اختيار حظر المكالمة أو إسكاتها فقط. والمكالمة المحظورة يمكن أيضًا ألّا تظهر في السجل ولا في الإشعارات. القواعد محفوظة في الجانب الأصلي (native) بصيغة JSON داخل SharedPreferences. والـ CallScreeningService يقرّر من القواعد المخزّنة ويجيب فورًا، دون انتظار Dart، فيعمل الحجب حتى لو لم يكن Flutter يعمل.
مطابقة الأرقام. الأرقام تُقارن برمز البلد مع الرقم الوطني. فالأرقام 0555123456 و+213555123456 و00213555123456 متطابقة. أما 0775123456 فلا يطابقها، رغم أن آخر 7 أرقام فيه هي نفسها.
سجل المكالمات. callLog.query(since:, type:, limit:, offset:) وcallLog.delete(ids).
واجهة المكالمة. عندما ترنّ مكالمة، تنشر الإضافة إشعار مكالمة واردة مع full-screen intent. وتستعمل Notification.CallStyle في API 31 وما بعده. والدالة setShowOverLockScreen تعرض واجهة Flutter فوق شاشة القفل.
أخطاء محددة الأنواع. كل دالة تُرجع Future عاديًّا. وعند الفشل ترمي صنفًا فرعيًّا من TelecomException، مثل TelecomPermissionException أو CallNotFoundException أو InCallServiceUnavailableException.
طريقة الاستعمال
أضِفها من GitHub:
dependencies:
android_telecom:
git: https://github.com/AbdeldjalilChougui/android_telecom
تحتاج minSdk 24. طلبات RoleManager وROLE_CALL_SCREENING وخيار الإسكات واتجاه المكالمة الذي يعطيه النظام تحتاج API 29، وNotification.CallStyle يحتاج API 31.
الـ manifest أولًا
يربط Android الـ InCallService لتطبيق الهاتف الافتراضي فقط، ولا يقترح تطبيقك في نافذة طلب الدور إلا إذا احتوى الـ manifest كل ما يلي:
-
InCallServiceمعBIND_INCALL_SERVICEوالـ metadata المسمّاةIN_CALL_SERVICE_UI؛ - activity تعالج
ACTION_DIAL، مرة دون بيانات ومرة مع المخططtel:.
الإضافة توفّر الخدمتين (TelecomInCallService وTelecomCallScreeningService)، وتطبيقك يصرّح بهما مع الصلاحيات التي يستعملها (CALL_PHONE وREAD_PHONE_STATE وREAD_CALL_LOG وWRITE_CALL_LOG وPOST_NOTIFICATIONS وUSE_FULL_SCREEN_INTENT). الـ README يحتوي الـ manifest كاملًا وتطبيق المثال يستعمله. والخدمتان صنفان open، فتستطيع أن ترث منهما وتوجّه الـ manifest إلى صنفك، بشرط أن تستدعي super في كل override.
من دون الدور، يبقى الـ stream calls فارغًا، وترمي دوال التحكم InCallServiceUnavailableException أو CallNotFoundException.
Dart
import 'package:android_telecom/android_telecom.dart';
final telecom = AndroidTelecom();
Future<void> setUp() async {
if (!await telecom.isDefaultDialer()) {
final granted = await telecom.requestDefaultDialer();
if (!granted) return; // the user said no
}
// Screening runs natively, even when Flutter is not running.
await telecom.screening.setRules(const ScreeningRules(
matcher: PhoneNumberMatcher(defaultCountryCode: '213'),
blockedNumbers: ['0555123456'],
blockPrivate: true,
action: ScreeningAction.block,
skipNotification: true,
));
}
void listen() {
telecom.calls.listen((calls) {
for (final call in calls) {
print('${call.id} ${call.number} ${call.state.name} ${call.direction.name}');
}
});
}
Future<void> controls(String callId) async {
try {
await telecom.answer(callId);
await telecom.setAudioRoute(AudioRoute.speaker);
await telecom.hold(callId);
await telecom.disconnect(callId);
} on CallNotFoundException {
// The call already ended.
} on TelecomException catch (e) {
print('telecom error ${e.code}: ${e.message}');
}
}
صلاحيات runtime يطلبها تطبيقك بنفسه، مثلًا بإضافة خاصة بالصلاحيات. وإذا نقصت واحدة، ترمي الإضافة TelecomPermissionException.
لإبقاء شاشة المكالمة ظاهرة فوق شاشة القفل ما دامت هناك مكالمة:
telecom.state.listen((s) {
telecom.setShowOverLockScreen(s.calls.isNotEmpty);
});
نصوص الإشعار تُغيَّر بـ configureCallNotifications، والقيمة CallNotificationConfig(enabled: false) تسمح لك بنشر إشعارك الخاص بدلًا منه.
مطابقة الأرقام بالتفصيل
const m = PhoneNumberMatcher(defaultCountryCode: '213'); // trunk '0', intl '00'
m.sameNumber('0555123456', '+213555123456'); // true
m.sameNumber('0555123456', '0775123456'); // false, only the last 7 digits agree
m.canonicalize('+213 (0)555 12 34 56'); // '+213555123456'
البادئة المحلية (trunk prefix) والبادئات الدولية قابلة للضبط، فيمكن إعداد المطابِق نفسه لبلدان أخرى. وصنف Dart وخدمة الحجب في Kotlin يطبّقان القواعد نفسها ويتشاركان حالات الاختبار نفسها.
ما يجب معرفته
- Android فقط. نظام iOS لا يسمح للتطبيقات الخارجية بتعويض تطبيق الهاتف.
-
سياسة Google Play. يقصر Play مجموعة صلاحيات Call Log على تطبيق الهاتف الافتراضي للمستخدم وقائمة قصيرة من الاستثناءات، ويطلب ملء نموذج Permissions Declaration Form. تطبيق الهاتف المبني بهذه الإضافة مؤهَّل بعد أن يختاره المستخدم، لكن عليك التصريح بذلك. و
USE_FULL_SCREEN_INTENTمقيّدة أيضًا في Android 14 وما بعده بتطبيقات الاتصال والمنبّهات. ما في الـ README تلخيص وليس استشارة قانونية؛ اقرأ سياسة Google الحالية قبل النشر. -
لا بيانات عن خطط الترقيم. الرقم المحفوظ بالصيغة الدولية دون
+أو00(مثل213555123456) يُقرأ كرقم وطني. والخيارsuffixMatchLengthبديل اختياري لمثل هذه البيانات. - "متصل مجهول" يخص طريقة عرض الرقم. معناه أن الرقم مخفي أو غير متاح أو فارغ. لا يعني "ليس في جهات الاتصال"، لأن الإضافة لا تقرأ جهات الاتصال.
-
تفاصيل الحجب.
skipCallLogوskipNotificationيعملان مع الحظر فقط، وقد يسجّل النظام المكالمات المحظورة بنوع "محظورة". والإسكات يحتاج API 29؛ في الإصدارات الأقدم ترنّ المكالمة عاديًّا. -
توجيه الصوت يستعمل
InCallService.setAudioRoute، وهي deprecated في API 34 لصالح call endpoints، لكنها ما زالت تعمل هناك. واختيار جهاز Bluetooth محدد غير متاح. - لا مكالمات فيديو. الرد يكون بالصوت فقط.
- فقدان الدور يسحب الصلاحيات التي منحها الدور، وAndroid يقتل العملية عند سحبها.
-
ما تم اختباره. واجهة Dart لها اختبارات وحدة بقنوات وهمية، ومنطق Kotlin الخالص (مطابِق الأرقام، محرك الحجب، جدول المكالمات، مسارات الصوت، حسابات الهاتف، ملفات الـ manifest) له اختبارات JUnit. ولتطبيق المثال اختبار تكامل على المحاكي يأخذ دور الهاتف، ويُجري مكالمات بـ
adb emu gsm call، ويتحقق من الرنين وanswerوholdوmuteوsetSpeakerو DTMF وdisconnectوrejectوالحجب وplaceCallوphoneAccountsوسجل المكالمات بمقارنتها معdumpsys telecom. -
ما لم يُختبر. نافذة طلب الدور، وإشعار المكالمة وأزراره، والمكالمات الجماعية (
mergeوswap)، والانتقال من مكبّر الصوت إلى سماعة الأذن، وتوجيه Bluetooth لم تُختبر على جهاز. كل الاختبارات جرت على محاكي Android (صورة Pixel 10، API 37)، وليس على هاتف حقيقي.
لماذا بنيتها
تطبيق هاتف بـ Flutter يحتاج عدة قطع من Android لا تعمل إلا مجتمعة: الدور، وInCallService مصرَّح به بالطريقة الصحيحة، وخدمة حجب، وإشعار مكالمة يستطيع إيقاظ هاتف مقفل، والوصول إلى سجل المكالمات. إن نقص سطر واحد في الـ manifest، لن يقترح النظام تطبيقك في النافذة أبدًا.
جزءان احتاجا عناية أكبر. الحجب يجب أن يجيب بسرعة، ولا يمكن أن يعتمد على محرك Flutter قد لا يكون شغّالًا، لذلك تعيش القواعد في الجانب الأصلي. ومقارنة الأرقام بآخر خاناتها تحظر أشخاصًا غير المقصودين، لذلك يعمل المطابِق برمز البلد والرقم الوطني، بالقواعد وحالات الاختبار نفسها في Dart وKotlin. أردت هذه القطع في plugin واحد، مع أخطاء محددة الأنواع وقائمة واضحة بما اختُبر وما لم يُختبر.
الكود
الكود، والـ README مع الـ manifest كاملًا، وتطبيق المثال، والاختبارات موجودة على GitHub: https://github.com/AbdeldjalilChougui/android_telecom. الرخصة MIT، والـ issues والـ pull requests مرحّب بها.
نُشر في الأصل على abdeldjalilchougui.biz.
Top comments (0)