commit a7f9c11a4e2f5fb1dac1058c0c96435d31f29a29 Author: wylyl22 <2373073266@qq.com> Date: Fri Aug 19 10:16:45 2022 +0800 first commit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..864f413 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +## 0.2.0 +* Upgrade `minSdk` to 23 +* Upgrade `targetSdk` to 31 +* Upgrade min dart sdk to 2.15.1 +* Fix SmsMethodCallHandler error. +* Added Service Center field in SmsMessage + +## 0.1.4 +* Fix SmsType parsing (Contributor: https://github.com/Mabsten) +* Remove SmsMethodCallHandler trailing comma. + +## 0.1.3 +* Fix background execution (Contributor: https://github.com/meomap) + +## 0.1.2 +* Change invokeMethod call type for getSms methods to List? (No change to telephony API) + +## 0.1.1 +* Added background instance for executing telephony methods in background. +* Fix type cast issues. + +## 0.1.0 +* Feature equivalent of v0.0.9 +* Enabled null-safety + +## 0.0.9 +* Fix sendSms Future never completes. + +## 0.0.8 +* Upgrade platform version. + +## 0.0.7 +* Fix build error when plugin included in iOS project. + +## 0.0.6 +* Multipart messages are grouped as one single SMS so that listenSms functions only get triggered once. + +## 0.0.5 +* Fix background execution error due to FlutterLoader.getInstance() deprecation. + +## 0.0.4 + +#### New Features: +* Start phone calls from default dialer or directly from the app. + +## 0.0.3 + +#### Changes: +* Fix unresponsive foreground methods after starting background isolate. + + +## 0.0.2 + +#### Possible breaking changes: +* sendSms functions are now async. + +#### Other changes: +* Adding documentation. +* Fix conflicting class name (Column --> TelephonyColumn). +* Update plugin description. + + +## 0.0.1 + +* First release of telephony + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..90a1630 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Shounak Mulay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e8eafe --- /dev/null +++ b/README.md @@ -0,0 +1,326 @@ +

+ + + + + + +

+ + +# Telephony +|:exclamation: This plugin currently only works on Android Platform| +|------------------------------------------------------------------| + + +A Flutter plugin to use telephony features such as +- Send SMS Messages +- Query SMS Messages +- Listen for incoming SMS +- Retrieve various network parameters +- Start phone calls + +This plugin tries to replicate some of the functionality provided by Android's [Telephony](https://developer.android.com/reference/android/provider/Telephony) class. + +Check the [Features section](#Features) to see the list of implemented and missing features. + +## Get Started +### :bulb: View the **[entire documentation here](https://telephony.shounakmulay.dev/)**. + +## Usage +To use this plugin add `telephony` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/packages-and-plugins/using-packages). + +##### Versions [0.0.9](https://pub.dev/packages/telephony/versions/0.0.9) and lower are not null safe. +##### Versions [0.1.0](https://pub.dev/packages/telephony/versions/0.1.0) and above opt into null safety. + + +### Setup +Import the `telephony` package +```dart +import 'package:telephony/telephony.dart'; +``` + + +Retrieve the singleton instance of `telephony` by calling +```dart +final Telephony telephony = Telephony.instance; +``` + +### [Permissions](https://shounakmulay.gitbook.io/telephony/permissions) +**Although this plugin will check and ask for permissions at runtime, it is advisable to _manually ask for permissions_ before calling any other functions.** + +The plugin will only request those permission that are listed in the `AndroidManifest.xml`. + +Manually request permission using +```dart +bool permissionsGranted = await telephony.requestPhoneAndSmsPermissions; +``` +You can also request SMS or Phone permissions separately using `requestSmsPermissions` or `requestPhonePermissions` respectively. + +### [Send SMS](https://shounakmulay.gitbook.io/telephony/sending-an-sms) +:exclamation: Requires `SEND_SMS` permission. +Add the following permission in your `AndroidManifest.xml` +```xml + +``` + +SMS can either be sent directly or via the default SMS app. + +#### Send SMS directly from your app: +```dart +telephony.sendSms( + to: "1234567890", + message: "May the force be with you!" + ); +``` +If you want to listen to the status of the message being sent, provide `SmsSendStatusListener` to the `sendSms` function. +```dart +final SmsSendStatusListener listener = (SendStatus status) { + // Handle the status + }; + +telephony.sendSms( + to: "1234567890", + message: "May the force be with you!", + statusListener: listener + ); +``` +If the body of the message is longer than the standard SMS length limit of `160 characters`, you can send a multipart SMS by setting the `isMultipart` flag. + +#### Send SMS via the default SMS app: +```dart +telephony.sendSmsByDefaultApp(to: "1234567890", message: "May the force be with you!"); +``` + +### [Query SMS](https://shounakmulay.gitbook.io/telephony/query-sms) +:exclamation: Requires `READ_SMS` permission. +Add the following permission in your `AndroidManifest.xml` +```xml + +``` + +Use one of `getInboxSms()`, `getSentSms()` or `getDraftSms()` functions to query the messages on device. + +You can provide the list of `SmsColumns` that need to be returned by the query. + +If not explicitly specified, defaults to `[ + SmsColumn.ID, + SmsColumn.ADDRESS, + SmsColumn.BODY, + SmsColumn.DATE +]` + +Provide a `SmsFilter` to filter the results of the query. Functions like a `SQL WHERE` clause. + +Provide a list of `OrderBy` objects to sort the results. The level of importance is determined by the position of `OrderBy` in the list. + +All paramaters are optional. +```dart +List messages = await telephony.getInboxSms( + columns: [SmsColumn.ADDRESS, SmsColumn.BODY], + filter: SmsFilter.where(SmsColumn.ADDRESS) + .equals("1234567890") + .and(SmsColumn.BODY) + .like("starwars"), + sortOrder: [OrderBy(SmsColumn.ADDRESS, sort: Sort.ASC), + OrderBy(SmsColumn.BODY)] + ); +``` + +### [Query Conversations](https://shounakmulay.gitbook.io/telephony/query-conversations) +:exclamation: Requires `READ_SMS` permission. +Add the following permission in your `AndroidManifest.xml` +```xml + +``` + +Works similar to [SMS queries](#query-sms). + +All columns are returned with every query. They are `[ + ConversationColumn.SNIPPET, + ConversationColumn.THREAD_ID, + ConversationColumn.MSG_COUNT +]` + +Uses `ConversationFilter` instead of `SmsFilter`. + +```dart +List messages = await telephony.getConversations( + filter: ConversationFilter.where(ConversationColumn.MSG_COUNT) + .equals("4") + .and(ConversationColumn.THREAD_ID) + .greaterThan("12"), + sortOrder: [OrderBy(ConversationColumn.THREAD_ID, sort: Sort.ASC)] + ); +``` + +### [Listen to incoming SMS](https://shounakmulay.gitbook.io/telephony/listen-incoming-sms) +:exclamation: Requires `RECEIVE_SMS` permission. + +1. To listen to incoming SMS add the `RECEIVE_SMS` permission to your `AndroidManifest.xml` file and register the `BroadcastReceiver`. + + +```xml + + + + + ... + ... + + + + + + + + + +``` + +2. Create a **top-level static function** to handle incoming messages when app is not is foreground. + + :warning: Avoid heavy computations in the background handler as Android system may kill long running operations in the background. + +```dart +backgrounMessageHandler(SmsMessage message) async { + //Handle background message +} + +void main() { + runApp(MyApp()); +} +``` +3. Call `listenIncomingSms` with a foreground `MessageHandler` and pass in the static `backgrounMessageHandler`. +```dart +telephony.listenIncomingSms( + onNewMessage: (SmsMessage message) { + // Handle message + }, + onBackgroundMessage: backgroundMessageHandler + ); +``` + +Preferably should be called early in app lifecycle. + +4. If you do not wish to receive incoming SMS when the app is in background, just do not pass the `onBackgroundMessage` paramater. + + Alternatively if you prefer to expecility disable background execution, set the `listenInBackground` flag to `false`. +```dart +telephony.listenIncomingSms( + onNewMessage: (SmsMessage message) { + // Handle message + }, + listenInBackground: false + ); +``` +5. As of the `1.12` release of Flutter, plugins are automatically registered. This will allow you to use plugins as you normally do even in the background execution context. +```dart +backgrounMessageHandler(SmsMessage message) async { + // Handle background message + + // Use plugins + Vibration.vibrate(duration: 500); + } +``` +### [Network data and metrics](https://shounakmulay.gitbook.io/telephony/network-data-and-metrics) + +Fetch various metrics such as `network type`, `sim state`, etc. + +```dart + +// Check if a device is capable of sending SMS +bool canSendSms = await telephony.isSmsCapable; + +// Get sim state +SimState simState = await telephony.simState; +``` + +Check out the [detailed documentation](https://shounakmulay.gitbook.io/telephony/network-data-and-metrics) to know all possible metrics and their values. + +### Executing in background +If you want to call the `telephony` methods in background, you can do in the following ways. + +#### 1. Using only `Telephony.instance` +If you want to continue using `Telephony.instance` in the background, you will need to make sure that once the app comes back to the front, it again calls `Telephony.instance`. +```dart +backgrounMessageHandler(SmsMessage message) async { + // Handle background message + Telephony.instance.sendSms(to: "123456789", message: "Message from background") +} + +void main() { + runApp(MyApp()); +} + +class _MyAppState extends State { + String _message; + // This will not work as the instance will be replaced by + // the one in background. + final telephony = Telephony.instance; + + @override + void initState() { + super.initState(); + // You should make sure call to instance is made every time + // app comes to foreground + final inbox = Telephony.instance.getInboxSms() + } + +``` + +#### 2. Use `backgroundInstance` +If you cannot make sure that the call to instance would be made every time app comes to foreground, or if you would prefer to maintain a separate background instance, +you can use `Telephony.backgroundInstance` in the background execution context. +```dart +backgrounMessageHandler(SmsMessage message) async { + // Handle background message + Telephony.backgroundInstance.sendSms(to: "123456789", message: "Message from background") +} + +void main() { + runApp(MyApp()); +} + +class _MyAppState extends State { + String _message; + final telephony = Telephony.instance; + + @override + void initState() { + super.initState(); + final inbox = telephony.getInboxSms() + } + +``` + + +## Features + + - [x] [Send SMS](#send-sms) + - [x] [Query SMS](#query-sms) + - [x] Inbox + - [x] Sent + - [x] Draft + - [x] [Query Conversations](#query-conversations) + - [x] [Listen to incoming SMS](#listen-to-incoming-sms) + - [x] When app is in foreground + - [x] When app is in background + - [x] [Network data and metrics](#network-data-and-metrics) + - [x] Cellular data state + - [x] Call state + - [x] Data activity + - [x] Network operator + - [x] Network operator name + - [x] Data network type + - [x] Phone type + - [x] Sim operator + - [x] Sim operator name + - [x] Sim state + - [x] Network roaming + - [x] Signal strength + - [x] Service state + - [x] Start Phone Call + - [ ] Schedule a SMS + - [ ] SMS Retriever API diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..bcc0321 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,48 @@ +group 'com.shounakmulay.telephony' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.6.21' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 31 + + kotlinOptions { + jvmTarget = "1.8" + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + defaultConfig { + minSdkVersion 23 + } + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.annotation:annotation:1.3.0' +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c7bff3 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Aug 23 12:29:06 IST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..0fc5a14 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'telephony' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..29aa6c7 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/PermissionsController.kt b/android/src/main/kotlin/com/shounakmulay/telephony/PermissionsController.kt new file mode 100644 index 0000000..0aa12ee --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/PermissionsController.kt @@ -0,0 +1,59 @@ +package com.shounakmulay.telephony + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import com.shounakmulay.telephony.utils.Constants.PHONE_PERMISSIONS +import com.shounakmulay.telephony.utils.Constants.SERVICE_STATE_PERMISSIONS +import com.shounakmulay.telephony.utils.Constants.SMS_PERMISSIONS + +class PermissionsController(private val context: Context) { + + var isRequestingPermission: Boolean = false + + fun hasRequiredPermissions(permissions: List): Boolean { + var hasPermissions = true + for (permission in permissions) { + hasPermissions = hasPermissions && checkPermission(permission) + } + return hasPermissions + } + + private fun checkPermission(permission: String): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || context.checkSelfPermission(permission) == PERMISSION_GRANTED + } + + @RequiresApi(Build.VERSION_CODES.M) + fun requestPermissions(activity: Activity, permissions: List, requestCode: Int) { + if (!isRequestingPermission) { + isRequestingPermission = true + activity.requestPermissions(permissions.toTypedArray(), requestCode) + } + } + + fun getSmsPermissions(): List { + val permissions = getListedPermissions() + return permissions.filter { permission -> SMS_PERMISSIONS.contains(permission) } + } + + fun getPhonePermissions(): List { + val permissions = getListedPermissions() + return permissions.filter { permission -> PHONE_PERMISSIONS.contains(permission) } + } + + fun getServiceStatePermissions(): List { + val permissions = getListedPermissions() + return permissions.filter { permission -> SERVICE_STATE_PERMISSIONS.contains(permission) } + } + + private fun getListedPermissions(): Array { + context.apply { + val info = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS) + return info.requestedPermissions ?: arrayOf() + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/TelephonyPlugin.kt b/android/src/main/kotlin/com/shounakmulay/telephony/TelephonyPlugin.kt new file mode 100644 index 0000000..58fc693 --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/TelephonyPlugin.kt @@ -0,0 +1,73 @@ +package com.shounakmulay.telephony + +import android.content.Context +import androidx.annotation.NonNull +import com.shounakmulay.telephony.sms.IncomingSmsHandler +import com.shounakmulay.telephony.utils.Constants.CHANNEL_SMS +import com.shounakmulay.telephony.sms.IncomingSmsReceiver +import com.shounakmulay.telephony.sms.SmsController +import com.shounakmulay.telephony.sms.SmsMethodCallHandler +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.* + + +class TelephonyPlugin : FlutterPlugin, ActivityAware { + + private lateinit var smsChannel: MethodChannel + + private lateinit var smsMethodCallHandler: SmsMethodCallHandler + + private lateinit var smsController: SmsController + + private lateinit var binaryMessenger: BinaryMessenger + + private lateinit var permissionsController: PermissionsController + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + if (!this::binaryMessenger.isInitialized) { + binaryMessenger = flutterPluginBinding.binaryMessenger + } + + setupPlugin(flutterPluginBinding.applicationContext, binaryMessenger) + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + tearDownPlugin() + } + + override fun onDetachedFromActivity() { + tearDownPlugin() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + IncomingSmsReceiver.foregroundSmsChannel = smsChannel + smsMethodCallHandler.setActivity(binding.activity) + binding.addRequestPermissionsResultListener(smsMethodCallHandler) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + private fun setupPlugin(context: Context, messenger: BinaryMessenger) { + smsController = SmsController(context) + permissionsController = PermissionsController(context) + smsMethodCallHandler = SmsMethodCallHandler(context, smsController, permissionsController) + + smsChannel = MethodChannel(messenger, CHANNEL_SMS) + smsChannel.setMethodCallHandler(smsMethodCallHandler) + smsMethodCallHandler.setForegroundChannel(smsChannel) + } + + private fun tearDownPlugin() { + IncomingSmsReceiver.foregroundSmsChannel = null + smsChannel.setMethodCallHandler(null) + } + +} diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/sms/ContextHolder.kt b/android/src/main/kotlin/com/shounakmulay/telephony/sms/ContextHolder.kt new file mode 100644 index 0000000..0021468 --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/sms/ContextHolder.kt @@ -0,0 +1,8 @@ +package com.shounakmulay.telephony.sms + +import android.content.Context +import com.shounakmulay.telephony.sms.ContextHolder + +object ContextHolder { + var applicationContext: Context? = null +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/sms/IncomingSmsHandler.kt b/android/src/main/kotlin/com/shounakmulay/telephony/sms/IncomingSmsHandler.kt new file mode 100644 index 0000000..b22ca6a --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/sms/IncomingSmsHandler.kt @@ -0,0 +1,269 @@ +package com.shounakmulay.telephony.sms + +import android.app.ActivityManager +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Process +import android.provider.Telephony +import android.telephony.SmsMessage +import com.shounakmulay.telephony.utils.Constants +import com.shounakmulay.telephony.utils.Constants.HANDLE +import com.shounakmulay.telephony.utils.Constants.HANDLE_BACKGROUND_MESSAGE +import com.shounakmulay.telephony.utils.Constants.MESSAGE +import com.shounakmulay.telephony.utils.Constants.MESSAGE_BODY +import com.shounakmulay.telephony.utils.Constants.ON_MESSAGE +import com.shounakmulay.telephony.utils.Constants.ORIGINATING_ADDRESS +import com.shounakmulay.telephony.utils.Constants.SERVICE_CENTER_ADDRESS +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFERENCES_NAME +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFS_BACKGROUND_MESSAGE_HANDLE +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFS_BACKGROUND_SETUP_HANDLE +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFS_DISABLE_BACKGROUND_EXE +import com.shounakmulay.telephony.utils.Constants.STATUS +import com.shounakmulay.telephony.utils.Constants.TIMESTAMP +import com.shounakmulay.telephony.utils.SmsAction +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterJNI +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.FlutterCallbackInformation +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.collections.HashMap + + +class IncomingSmsReceiver : BroadcastReceiver() { + + companion object { + var foregroundSmsChannel: MethodChannel? = null + } + + override fun onReceive(context: Context, intent: Intent?) { + ContextHolder.applicationContext = context.applicationContext + val smsList = Telephony.Sms.Intents.getMessagesFromIntent(intent) + val messagesGroupedByOriginatingAddress = smsList.groupBy { it.originatingAddress } + messagesGroupedByOriginatingAddress.forEach { group -> + processIncomingSms(context, group.value) + } + } + + /** + * Calls [ON_MESSAGE] method on the Foreground Channel if the application is in foreground. + * + * If the application is not in the foreground and the background isolate is not running, it initializes the + * background isolate. The SMS is added to a background queue that will be processed on the isolate is initialized. + * + * If the application is not in the foreground but the the background isolate is running, it calls the + * [IncomingSmsHandler.executeDartCallbackInBackgroundIsolate] with the SMS. + * + */ + private fun processIncomingSms(context: Context, smsList: List) { + val messageMap = smsList.first().toMap() + smsList.forEachIndexed { index, smsMessage -> + if (index > 0) { + messageMap[MESSAGE_BODY] = (messageMap[MESSAGE_BODY] as String) + .plus(smsMessage.messageBody.trim()) + } + } + if (IncomingSmsHandler.isApplicationForeground(context)) { + val args = HashMap() + args[MESSAGE] = messageMap + foregroundSmsChannel?.invokeMethod(ON_MESSAGE, args) + } else { + val preferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + val disableBackground = + preferences.getBoolean(SHARED_PREFS_DISABLE_BACKGROUND_EXE, false) + if (!disableBackground) { + processInBackground(context, messageMap) + } + } + } + + private fun processInBackground(context: Context, sms: HashMap) { + IncomingSmsHandler.apply { + if (!isIsolateRunning.get()) { + initialize(context) + val preferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + val backgroundCallbackHandle = + preferences.getLong(SHARED_PREFS_BACKGROUND_SETUP_HANDLE, 0) + startBackgroundIsolate(context, backgroundCallbackHandle) + backgroundMessageQueue.add(sms) + } else { + executeDartCallbackInBackgroundIsolate(context, sms) + } + } + } +} + +/** + * Convert the [SmsMessage] to a [HashMap] + */ +fun SmsMessage.toMap(): HashMap { + val smsMap = HashMap() + this.apply { + smsMap[MESSAGE_BODY] = messageBody + smsMap[TIMESTAMP] = timestampMillis.toString() + smsMap[ORIGINATING_ADDRESS] = originatingAddress + smsMap[STATUS] = status.toString() + smsMap[SERVICE_CENTER_ADDRESS] = serviceCenterAddress + } + return smsMap +} + +/** + * Handle all the background processing on received SMS + * + * Call [setBackgroundSetupHandle] and [setBackgroundMessageHandle] before performing any other operations. + * + * + * Will throw [RuntimeException] if [backgroundChannel] was not initialized by calling [startBackgroundIsolate] + * before calling [executeDartCallbackInBackgroundIsolate] + */ +object IncomingSmsHandler : MethodChannel.MethodCallHandler { + + internal val backgroundMessageQueue = + Collections.synchronizedList(mutableListOf>()) + internal var isIsolateRunning = AtomicBoolean(false) + + private lateinit var backgroundChannel: MethodChannel + private lateinit var backgroundFlutterEngine: FlutterEngine + private lateinit var flutterLoader: FlutterLoader + + private var backgroundMessageHandle: Long? = null + + /** + * Initializes a background flutter execution environment and executes the callback + * to setup the background [MethodChannel] + * + * Also initializes the method channel on the android side + */ + fun startBackgroundIsolate(context: Context, callbackHandle: Long) { + val appBundlePath = flutterLoader.findAppBundlePath() + val flutterCallback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + + val dartEntryPoint = + DartExecutor.DartCallback(context.assets, appBundlePath, flutterCallback) + + backgroundFlutterEngine = FlutterEngine(context, flutterLoader, FlutterJNI()) + backgroundFlutterEngine.dartExecutor.executeDartCallback(dartEntryPoint) + + backgroundChannel = + MethodChannel(backgroundFlutterEngine.dartExecutor, Constants.CHANNEL_SMS_BACKGROUND) + backgroundChannel.setMethodCallHandler(this) + } + + /** + * Called when the background dart isolate has completed setting up the method channel + * + * If any SMS were received during the background isolate was being initialized, it will process + * all those messages. + */ + fun onChannelInitialized(applicationContext: Context) { + isIsolateRunning.set(true) + synchronized(backgroundMessageQueue) { + + // Handle all the messages received before the Dart isolate was + // initialized, then clear the queue. + val iterator = backgroundMessageQueue.iterator() + while (iterator.hasNext()) { + executeDartCallbackInBackgroundIsolate(applicationContext, iterator.next()) + } + backgroundMessageQueue.clear() + } + } + + /** + * Invoke the method on background channel to handle the message + */ + internal fun executeDartCallbackInBackgroundIsolate( + context: Context, + message: HashMap + ) { + if (!this::backgroundChannel.isInitialized) { + throw RuntimeException( + "setBackgroundChannel was not called before messages came in, exiting." + ) + } + + val args: MutableMap = HashMap() + if (backgroundMessageHandle == null) { + backgroundMessageHandle = getBackgroundMessageHandle(context) + } + args[HANDLE] = backgroundMessageHandle + args[MESSAGE] = message + backgroundChannel.invokeMethod(HANDLE_BACKGROUND_MESSAGE, args) + } + + /** + * Gets an instance of FlutterLoader from the FlutterInjector, starts initialization and + * waits until initialization is complete. + * + * Should be called before invoking any other background methods. + */ + internal fun initialize(context: Context) { + val flutterInjector = FlutterInjector.instance() + flutterLoader = flutterInjector.flutterLoader() + flutterLoader.startInitialization(context) + flutterLoader.ensureInitializationComplete(context.applicationContext, null) + } + + fun setBackgroundMessageHandle(context: Context, handle: Long) { + backgroundMessageHandle = handle + + // Store background message handle in shared preferences so it can be retrieved + // by other application instances. + val preferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + preferences.edit().putLong(SHARED_PREFS_BACKGROUND_MESSAGE_HANDLE, handle).apply() + + } + + fun setBackgroundSetupHandle(context: Context, setupBackgroundHandle: Long) { + // Store background setup handle in shared preferences so it can be retrieved + // by other application instances. + val preferences = + context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + preferences.edit().putLong(SHARED_PREFS_BACKGROUND_SETUP_HANDLE, setupBackgroundHandle) + .apply() + } + + private fun getBackgroundMessageHandle(context: Context): Long { + return context + .getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + .getLong(SHARED_PREFS_BACKGROUND_MESSAGE_HANDLE, 0) + } + + fun isApplicationForeground(context: Context): Boolean { + val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (keyguardManager.isKeyguardLocked) { + return false + } + val myPid = Process.myPid() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + var list: List + if (activityManager.runningAppProcesses.also { list = it } != null) { + for (aList in list) { + var info: ActivityManager.RunningAppProcessInfo + if (aList.also { info = it }.pid == myPid) { + return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + } + } + return false + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (SmsAction.fromMethod(call.method) == SmsAction.BACKGROUND_SERVICE_INITIALIZED) { + onChannelInitialized( + ContextHolder.applicationContext + ?: throw RuntimeException("Context not initialised!") + ) + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsController.kt b/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsController.kt new file mode 100644 index 0000000..dea8ce4 --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsController.kt @@ -0,0 +1,266 @@ +package com.shounakmulay.telephony.sms + +import android.Manifest +import android.Manifest.permission.READ_PHONE_STATE +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.telephony.* +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getSystemService +import com.shounakmulay.telephony.utils.Constants.ACTION_SMS_DELIVERED +import com.shounakmulay.telephony.utils.Constants.ACTION_SMS_SENT +import com.shounakmulay.telephony.utils.Constants.SMS_BODY +import com.shounakmulay.telephony.utils.Constants.SMS_DELIVERED_BROADCAST_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.SMS_SENT_BROADCAST_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.SMS_TO +import com.shounakmulay.telephony.utils.ContentUri +import java.lang.RuntimeException + + +class SmsController(private val context: Context) { + + // FETCH SMS + fun getMessages( + contentUri: ContentUri, + projection: List, + selection: String?, + selectionArgs: List?, + sortOrder: String? + ): List> { + val messages = mutableListOf>() + + val cursor = context.contentResolver.query( + contentUri.uri, + projection.toTypedArray(), + selection, + selectionArgs?.toTypedArray(), + sortOrder + ) + + while (cursor != null && cursor.moveToNext()) { + val dataObject = HashMap(projection.size) + for (columnName in cursor.columnNames) { + val columnIndex = cursor.getColumnIndex(columnName) + if (columnIndex >= 0) { + val value = cursor.getString(columnIndex) + dataObject[columnName] = value + } + } + messages.add(dataObject) + } + cursor?.close() + return messages + } + + // SEND SMS + fun sendSms(destinationAddress: String, messageBody: String, listenStatus: Boolean) { + val smsManager = getSmsManager() + if (listenStatus) { + val pendingIntents = getPendingIntents() + smsManager.sendTextMessage( + destinationAddress, + null, + messageBody, + pendingIntents.first, + pendingIntents.second + ) + } else { + smsManager.sendTextMessage(destinationAddress, null, messageBody, null, null) + } + } + + fun sendMultipartSms(destinationAddress: String, messageBody: String, listenStatus: Boolean) { + val smsManager = getSmsManager() + val messageParts = smsManager.divideMessage(messageBody) + if (listenStatus) { + val pendingIntents = getMultiplePendingIntents(messageParts.size) + smsManager.sendMultipartTextMessage( + destinationAddress, + null, + messageParts, + pendingIntents.first, + pendingIntents.second + ) + } else { + smsManager.sendMultipartTextMessage(destinationAddress, null, messageParts, null, null) + } + } + + private fun getMultiplePendingIntents(size: Int): Pair, ArrayList> { + val sentPendingIntents = arrayListOf() + val deliveredPendingIntents = arrayListOf() + for (i in 1..size) { + val pendingIntents = getPendingIntents() + sentPendingIntents.add(pendingIntents.first) + deliveredPendingIntents.add(pendingIntents.second) + } + return Pair(sentPendingIntents, deliveredPendingIntents) + } + + fun sendSmsIntent(destinationAddress: String, messageBody: String) { + val intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse(SMS_TO + destinationAddress) + putExtra(SMS_BODY, messageBody) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.applicationContext.startActivity(intent) + } + + private fun getPendingIntents(): Pair { + val sentIntent = Intent(ACTION_SMS_SENT).apply { + `package` = context.applicationContext.packageName + flags = Intent.FLAG_RECEIVER_REGISTERED_ONLY + } + val sentPendingIntent = PendingIntent.getBroadcast( + context, + SMS_SENT_BROADCAST_REQUEST_CODE, + sentIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + val deliveredIntent = Intent(ACTION_SMS_DELIVERED).apply { + `package` = context.applicationContext.packageName + flags = Intent.FLAG_RECEIVER_REGISTERED_ONLY + } + val deliveredPendingIntent = PendingIntent.getBroadcast( + context, + SMS_DELIVERED_BROADCAST_REQUEST_CODE, + deliveredIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + return Pair(sentPendingIntent, deliveredPendingIntent) + } + + private fun getSmsManager(): SmsManager { + val subscriptionId = SmsManager.getDefaultSmsSubscriptionId() +// val smsManager = getSystemService(context, SmsManager::class.java) + val smsManager = SmsManager.getDefault() + ?: throw RuntimeException("Flutter Telephony: Error getting SmsManager") + if (subscriptionId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + smsManager.createForSubscriptionId(subscriptionId) + } else { + SmsManager.getSmsManagerForSubscriptionId(subscriptionId) + } + } + return smsManager + } + + // PHONE + fun openDialer(phoneNumber: String) { + val dialerIntent = Intent(Intent.ACTION_DIAL).apply { + data = Uri.parse("tel:$phoneNumber") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + context.startActivity(dialerIntent) + } + + @RequiresPermission(allOf = [Manifest.permission.CALL_PHONE]) + fun dialPhoneNumber(phoneNumber: String) { + val callIntent = Intent(Intent.ACTION_CALL).apply { + data = Uri.parse("tel:$phoneNumber") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + if (callIntent.resolveActivity(context.packageManager) != null) { + context.applicationContext.startActivity(callIntent) + } + } + + // STATUS + fun isSmsCapable(): Boolean { + val telephonyManager = getTelephonyManager() + return telephonyManager.isSmsCapable + } + + fun getCellularDataState(): Int { + return getTelephonyManager().dataState + } + + @RequiresPermission(Manifest.permission.READ_PHONE_STATE) + fun getCallState(): Int { + val telephonyManager = getTelephonyManager() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + telephonyManager.callStateForSubscription + } else { + telephonyManager.callState + } + } + + fun getDataActivity(): Int { + return getTelephonyManager().dataActivity + } + + fun getNetworkOperator(): String { + return getTelephonyManager().networkOperator + } + + fun getNetworkOperatorName(): String { + return getTelephonyManager().networkOperatorName + } + + @SuppressLint("MissingPermission") + fun getDataNetworkType(): Int { + val telephonyManager = getTelephonyManager() + return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { + telephonyManager.dataNetworkType + } else { + telephonyManager.networkType + } + } + + fun getPhoneType(): Int { + return getTelephonyManager().phoneType + } + + fun getSimOperator(): String { + return getTelephonyManager().simOperator + } + + fun getSimOperatorName(): String { + return getTelephonyManager().simOperatorName + } + + fun getSimState(): Int { + return getTelephonyManager().simState + } + + fun isNetworkRoaming(): Boolean { + return getTelephonyManager().isNetworkRoaming + } + + @RequiresApi(Build.VERSION_CODES.O) + @RequiresPermission(allOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.READ_PHONE_STATE]) + fun getServiceState(): Int? { + val serviceState = getTelephonyManager().serviceState + return serviceState?.state + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun getSignalStrength(): List? { + val signalStrength = getTelephonyManager().signalStrength + return signalStrength?.cellSignalStrengths?.map { + return@map it.level + } + } + + private fun getTelephonyManager(): TelephonyManager { + val subscriptionId = SmsManager.getDefaultSmsSubscriptionId() + val telephonyManager = + context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + telephonyManager.createForSubscriptionId(subscriptionId) + } else { + telephonyManager + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsMethodCallHandler.kt b/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsMethodCallHandler.kt new file mode 100644 index 0000000..e220ba2 --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsMethodCallHandler.kt @@ -0,0 +1,401 @@ +package com.shounakmulay.telephony.sms + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import com.shounakmulay.telephony.PermissionsController +import com.shounakmulay.telephony.utils.ActionType +import com.shounakmulay.telephony.utils.Constants +import com.shounakmulay.telephony.utils.Constants.ADDRESS +import com.shounakmulay.telephony.utils.Constants.BACKGROUND_HANDLE +import com.shounakmulay.telephony.utils.Constants.CALL_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.DEFAULT_CONVERSATION_PROJECTION +import com.shounakmulay.telephony.utils.Constants.DEFAULT_SMS_PROJECTION +import com.shounakmulay.telephony.utils.Constants.FAILED_FETCH +import com.shounakmulay.telephony.utils.Constants.GET_STATUS_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.ILLEGAL_ARGUMENT +import com.shounakmulay.telephony.utils.Constants.LISTEN_STATUS +import com.shounakmulay.telephony.utils.Constants.MESSAGE_BODY +import com.shounakmulay.telephony.utils.Constants.PERMISSION_DENIED +import com.shounakmulay.telephony.utils.Constants.PERMISSION_DENIED_MESSAGE +import com.shounakmulay.telephony.utils.Constants.PERMISSION_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.PHONE_NUMBER +import com.shounakmulay.telephony.utils.Constants.PROJECTION +import com.shounakmulay.telephony.utils.Constants.SELECTION +import com.shounakmulay.telephony.utils.Constants.SELECTION_ARGS +import com.shounakmulay.telephony.utils.Constants.SETUP_HANDLE +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFERENCES_NAME +import com.shounakmulay.telephony.utils.Constants.SHARED_PREFS_DISABLE_BACKGROUND_EXE +import com.shounakmulay.telephony.utils.Constants.SMS_BACKGROUND_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.SMS_DELIVERED +import com.shounakmulay.telephony.utils.Constants.SMS_QUERY_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.SMS_SEND_REQUEST_CODE +import com.shounakmulay.telephony.utils.Constants.SMS_SENT +import com.shounakmulay.telephony.utils.Constants.SORT_ORDER +import com.shounakmulay.telephony.utils.Constants.WRONG_METHOD_TYPE +import com.shounakmulay.telephony.utils.ContentUri +import com.shounakmulay.telephony.utils.SmsAction +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry + + +class SmsMethodCallHandler( + private val context: Context, + private val smsController: SmsController, + private val permissionsController: PermissionsController +) : PluginRegistry.RequestPermissionsResultListener, + MethodChannel.MethodCallHandler, + BroadcastReceiver() { + + private lateinit var result: MethodChannel.Result + private lateinit var action: SmsAction + private lateinit var foregroundChannel: MethodChannel + private lateinit var activity: Activity + + private var projection: List? = null + private var selection: String? = null + private var selectionArgs: List? = null + private var sortOrder: String? = null + + private lateinit var messageBody: String + private lateinit var address: String + private var listenStatus: Boolean = false + + private var setupHandle: Long = -1 + private var backgroundHandle: Long = -1 + + private lateinit var phoneNumber: String + + private var requestCode: Int = -1 + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + this.result = result + + action = SmsAction.fromMethod(call.method) + + if (action == SmsAction.NO_SUCH_METHOD) { + result.notImplemented() + return + } + + when (action.toActionType()) { + ActionType.GET_SMS -> { + projection = call.argument(PROJECTION) + selection = call.argument(SELECTION) + selectionArgs = call.argument(SELECTION_ARGS) + sortOrder = call.argument(SORT_ORDER) + + handleMethod(action, SMS_QUERY_REQUEST_CODE) + } + ActionType.SEND_SMS -> { + if (call.hasArgument(MESSAGE_BODY) + && call.hasArgument(ADDRESS)) { + val messageBody = call.argument(MESSAGE_BODY) + val address = call.argument(ADDRESS) + if (messageBody.isNullOrBlank() || address.isNullOrBlank()) { + result.error(ILLEGAL_ARGUMENT, Constants.MESSAGE_OR_ADDRESS_CANNOT_BE_NULL, null) + return + } + + this.messageBody = messageBody + this.address = address + + listenStatus = call.argument(LISTEN_STATUS) ?: false + } + handleMethod(action, SMS_SEND_REQUEST_CODE) + } + ActionType.BACKGROUND -> { + if (call.hasArgument(SETUP_HANDLE) + && call.hasArgument(BACKGROUND_HANDLE)) { + val setupHandle = call.argument(SETUP_HANDLE) + val backgroundHandle = call.argument(BACKGROUND_HANDLE) + if (setupHandle == null || backgroundHandle == null) { + result.error(ILLEGAL_ARGUMENT, "Setup handle or background handle missing", null) + return + } + + this.setupHandle = setupHandle + this.backgroundHandle = backgroundHandle + } + handleMethod(action, SMS_BACKGROUND_REQUEST_CODE) + } + ActionType.GET -> handleMethod(action, GET_STATUS_REQUEST_CODE) + ActionType.PERMISSION -> handleMethod(action, PERMISSION_REQUEST_CODE) + ActionType.CALL -> { + if (call.hasArgument(PHONE_NUMBER)) { + val phoneNumber = call.argument(PHONE_NUMBER) + + if (!phoneNumber.isNullOrBlank()) { + this.phoneNumber = phoneNumber + } + + handleMethod(action, CALL_REQUEST_CODE) + } + } + } + } + + /** + * Called by [handleMethod] after checking the permissions. + * + * ##### + * + * If permission was not previously granted, [handleMethod] will request the user for permission + * + * Once user grants the permission this method will be executed. + * + * ##### + */ + private fun execute(smsAction: SmsAction) { + try { + when (smsAction.toActionType()) { + ActionType.GET_SMS -> handleGetSmsActions(smsAction) + ActionType.SEND_SMS -> handleSendSmsActions(smsAction) + ActionType.BACKGROUND -> handleBackgroundActions(smsAction) + ActionType.GET -> handleGetActions(smsAction) + ActionType.PERMISSION -> result.success(true) + ActionType.CALL -> handleCallActions(smsAction) + } + } catch (e: IllegalArgumentException) { + result.error(ILLEGAL_ARGUMENT, WRONG_METHOD_TYPE, null) + } catch (e: RuntimeException) { + result.error(FAILED_FETCH, e.message, null) + } + } + + private fun handleGetSmsActions(smsAction: SmsAction) { + if (projection == null) { + projection = if (smsAction == SmsAction.GET_CONVERSATIONS) DEFAULT_CONVERSATION_PROJECTION else DEFAULT_SMS_PROJECTION + } + val contentUri = when (smsAction) { + SmsAction.GET_INBOX -> ContentUri.INBOX + SmsAction.GET_SENT -> ContentUri.SENT + SmsAction.GET_DRAFT -> ContentUri.DRAFT + SmsAction.GET_CONVERSATIONS -> ContentUri.CONVERSATIONS + else -> throw IllegalArgumentException() + } + val messages = smsController.getMessages(contentUri, projection!!, selection, selectionArgs, sortOrder) + result.success(messages) + } + + private fun handleSendSmsActions(smsAction: SmsAction) { + if (listenStatus) { + val intentFilter = IntentFilter().apply { + addAction(Constants.ACTION_SMS_SENT) + addAction(Constants.ACTION_SMS_DELIVERED) + } + context.applicationContext.registerReceiver(this, intentFilter) + } + when (smsAction) { + SmsAction.SEND_SMS -> smsController.sendSms(address, messageBody, listenStatus) + SmsAction.SEND_MULTIPART_SMS -> smsController.sendMultipartSms(address, messageBody, listenStatus) + SmsAction.SEND_SMS_INTENT -> smsController.sendSmsIntent(address, messageBody) + else -> throw IllegalArgumentException() + } + result.success(null) + } + + private fun handleBackgroundActions(smsAction: SmsAction) { + when (smsAction) { + SmsAction.START_BACKGROUND_SERVICE -> { + val preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + preferences.edit().putBoolean(SHARED_PREFS_DISABLE_BACKGROUND_EXE, false).apply() + IncomingSmsHandler.setBackgroundSetupHandle(context, setupHandle) + IncomingSmsHandler.setBackgroundMessageHandle(context, backgroundHandle) + } + SmsAction.BACKGROUND_SERVICE_INITIALIZED -> { + IncomingSmsHandler.onChannelInitialized(context.applicationContext) + } + SmsAction.DISABLE_BACKGROUND_SERVICE -> { + val preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + preferences.edit().putBoolean(SHARED_PREFS_DISABLE_BACKGROUND_EXE, true).apply() + } + else -> throw IllegalArgumentException() + } + } + + @SuppressLint("MissingPermission") + private fun handleGetActions(smsAction: SmsAction) { + smsController.apply { + val value: Any = when (smsAction) { + SmsAction.IS_SMS_CAPABLE -> isSmsCapable() + SmsAction.GET_CELLULAR_DATA_STATE -> getCellularDataState() + SmsAction.GET_CALL_STATE -> getCallState() + SmsAction.GET_DATA_ACTIVITY -> getDataActivity() + SmsAction.GET_NETWORK_OPERATOR -> getNetworkOperator() + SmsAction.GET_NETWORK_OPERATOR_NAME -> getNetworkOperatorName() + SmsAction.GET_DATA_NETWORK_TYPE -> getDataNetworkType() + SmsAction.GET_PHONE_TYPE -> getPhoneType() + SmsAction.GET_SIM_OPERATOR -> getSimOperator() + SmsAction.GET_SIM_OPERATOR_NAME -> getSimOperatorName() + SmsAction.GET_SIM_STATE -> getSimState() + SmsAction.IS_NETWORK_ROAMING -> isNetworkRoaming() + SmsAction.GET_SIGNAL_STRENGTH -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + getSignalStrength() + ?: result.error("SERVICE_STATE_NULL", "Error getting service state", null) + + } else { + result.error("INCORRECT_SDK_VERSION", "getServiceState() can only be called on Android Q and above", null) + } + } + SmsAction.GET_SERVICE_STATE -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getServiceState() + ?: result.error("SERVICE_STATE_NULL", "Error getting service state", null) + } else { + result.error("INCORRECT_SDK_VERSION", "getServiceState() can only be called on Android O and above", null) + } + } + else -> throw IllegalArgumentException() + } + result.success(value) + } + } + + @SuppressLint("MissingPermission") + private fun handleCallActions(smsAction: SmsAction) { + when (smsAction) { + SmsAction.OPEN_DIALER -> smsController.openDialer(phoneNumber) + SmsAction.DIAL_PHONE_NUMBER -> smsController.dialPhoneNumber(phoneNumber) + else -> throw IllegalArgumentException() + } + } + + + /** + * Calls the [execute] method after checking if the necessary permissions are granted. + * + * If not granted then it will request the permission from the user. + */ + private fun handleMethod(smsAction: SmsAction, requestCode: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || checkOrRequestPermission(smsAction, requestCode)) { + execute(smsAction) + } + } + + /** + * Check and request if necessary for all the SMS permissions listed in the manifest + */ + @RequiresApi(Build.VERSION_CODES.M) + fun checkOrRequestPermission(smsAction: SmsAction, requestCode: Int): Boolean { + this.action = smsAction + this.requestCode = requestCode + when (smsAction) { + SmsAction.GET_INBOX, + SmsAction.GET_SENT, + SmsAction.GET_DRAFT, + SmsAction.GET_CONVERSATIONS, + SmsAction.SEND_SMS, + SmsAction.SEND_MULTIPART_SMS, + SmsAction.SEND_SMS_INTENT, + SmsAction.START_BACKGROUND_SERVICE, + SmsAction.BACKGROUND_SERVICE_INITIALIZED, + SmsAction.DISABLE_BACKGROUND_SERVICE, + SmsAction.REQUEST_SMS_PERMISSIONS -> { + val permissions = permissionsController.getSmsPermissions() + return checkOrRequestPermission(permissions, requestCode) + } + SmsAction.GET_DATA_NETWORK_TYPE, + SmsAction.OPEN_DIALER, + SmsAction.DIAL_PHONE_NUMBER, + SmsAction.REQUEST_PHONE_PERMISSIONS -> { + val permissions = permissionsController.getPhonePermissions() + return checkOrRequestPermission(permissions, requestCode) + } + SmsAction.GET_SERVICE_STATE -> { + val permissions = permissionsController.getServiceStatePermissions() + return checkOrRequestPermission(permissions, requestCode) + } + SmsAction.REQUEST_PHONE_AND_SMS_PERMISSIONS -> { + val permissions = listOf(permissionsController.getSmsPermissions(), permissionsController.getPhonePermissions()).flatten() + return checkOrRequestPermission(permissions, requestCode) + } + SmsAction.IS_SMS_CAPABLE, + SmsAction.GET_CELLULAR_DATA_STATE, + SmsAction.GET_CALL_STATE, + SmsAction.GET_DATA_ACTIVITY, + SmsAction.GET_NETWORK_OPERATOR, + SmsAction.GET_NETWORK_OPERATOR_NAME, + SmsAction.GET_PHONE_TYPE, + SmsAction.GET_SIM_OPERATOR, + SmsAction.GET_SIM_OPERATOR_NAME, + SmsAction.GET_SIM_STATE, + SmsAction.IS_NETWORK_ROAMING, + SmsAction.GET_SIGNAL_STRENGTH, + SmsAction.NO_SUCH_METHOD -> return true + } + } + + fun setActivity(activity: Activity) { + this.activity = activity + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun checkOrRequestPermission(permissions: List, requestCode: Int): Boolean { + permissionsController.apply { + + if (!::activity.isInitialized) { + return hasRequiredPermissions(permissions) + } + + if (!hasRequiredPermissions(permissions)) { + requestPermissions(activity, permissions, requestCode) + return false + } + return true + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + + permissionsController.isRequestingPermission = false + + val deniedPermissions = mutableListOf() + if (requestCode != this.requestCode && !this::action.isInitialized) { + return false + } + + val allPermissionGranted = grantResults.foldIndexed(true) { i, acc, result -> + if (result == PackageManager.PERMISSION_DENIED) { + permissions.let { deniedPermissions.add(it[i]) } + } + return@foldIndexed acc && result == PackageManager.PERMISSION_GRANTED + } + + return if (allPermissionGranted) { + execute(action) + true + } else { + onPermissionDenied(deniedPermissions) + false + } + } + + private fun onPermissionDenied(deniedPermissions: List) { + result.error(PERMISSION_DENIED, PERMISSION_DENIED_MESSAGE, deniedPermissions) + } + + fun setForegroundChannel(channel: MethodChannel) { + foregroundChannel = channel + } + + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent != null) { + when (intent.action) { + Constants.ACTION_SMS_SENT -> foregroundChannel.invokeMethod(SMS_SENT, null) + Constants.ACTION_SMS_DELIVERED -> { + foregroundChannel.invokeMethod(SMS_DELIVERED, null) + context.unregisterReceiver(this) + } + } + } + } +} diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/utils/Constants.kt b/android/src/main/kotlin/com/shounakmulay/telephony/utils/Constants.kt new file mode 100644 index 0000000..df79791 --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/utils/Constants.kt @@ -0,0 +1,84 @@ +package com.shounakmulay.telephony.utils + +import android.Manifest +import android.provider.Telephony + +object Constants { + + // Channels + const val CHANNEL_SMS = "plugins.shounakmulay.com/foreground_sms_channel" + const val CHANNEL_SMS_BACKGROUND = "plugins.shounakmulay.com/background_sms_channel" + + // Intent Actions + const val ACTION_SMS_SENT = "plugins.shounakmulay.intent.ACTION_SMS_SENT" + const val ACTION_SMS_DELIVERED = "plugins.shounakmulay.intent.ACTION_SMS_DELIVERED" + + + + // Permissions + val SMS_PERMISSIONS = listOf(Manifest.permission.READ_SMS, Manifest.permission.SEND_SMS, Manifest.permission.RECEIVE_SMS) + val PHONE_PERMISSIONS = listOf(Manifest.permission.READ_PHONE_STATE, Manifest.permission.CALL_PHONE) + val SERVICE_STATE_PERMISSIONS = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.READ_PHONE_STATE) + + // Request Codes + const val SMS_QUERY_REQUEST_CODE = 1 + const val SMS_SEND_REQUEST_CODE = 2 + const val SMS_SENT_BROADCAST_REQUEST_CODE = 21 + const val SMS_DELIVERED_BROADCAST_REQUEST_CODE = 22 + const val SMS_BACKGROUND_REQUEST_CODE = 31 + const val GET_STATUS_REQUEST_CODE = 41 + const val PERMISSION_REQUEST_CODE = 51 + const val CALL_REQUEST_CODE = 61 + + // Methods + const val ON_MESSAGE = "onMessage" + const val HANDLE_BACKGROUND_MESSAGE = "handleBackgroundMessage" + const val SMS_SENT = "smsSent" + const val SMS_DELIVERED = "smsDelivered" + + // Invoke Method Arguments + const val HANDLE = "handle" + const val MESSAGE = "message" + + // Method Call Arguments + const val PROJECTION = "projection" + const val SELECTION = "selection" + const val SELECTION_ARGS = "selection_args" + const val SORT_ORDER = "sort_order" + const val MESSAGE_BODY = "message_body" + const val ADDRESS = "address" + const val LISTEN_STATUS = "listen_status" + const val SERVICE_CENTER_ADDRESS = "service_center" + + const val TIMESTAMP = "timestamp" + const val ORIGINATING_ADDRESS = "originating_address" + const val STATUS = "status" + + const val SETUP_HANDLE = "setupHandle" + const val BACKGROUND_HANDLE = "backgroundHandle" + + const val PHONE_NUMBER = "phoneNumber" + + // Projections + val DEFAULT_SMS_PROJECTION = listOf(Telephony.Sms._ID, Telephony.Sms.ADDRESS, Telephony.Sms.BODY, Telephony.Sms.DATE) + val DEFAULT_CONVERSATION_PROJECTION = listOf(Telephony.Sms.Conversations.THREAD_ID ,Telephony.Sms.Conversations.SNIPPET, Telephony.Sms.Conversations.MESSAGE_COUNT) + + + // Strings + const val PERMISSION_DENIED = "permission_denied" + const val PERMISSION_DENIED_MESSAGE = "Permission Request Denied By User." + const val FAILED_FETCH = "failed_to_fetch_sms" + const val ILLEGAL_ARGUMENT = "illegal_argument" + const val WRONG_METHOD_TYPE = "Incorrect method called on channel." + const val MESSAGE_OR_ADDRESS_CANNOT_BE_NULL = "Message body or Address cannot be null or blank." + + const val SMS_TO = "smsto:" + const val SMS_BODY = "sms_body" + + // Shared Preferences + const val SHARED_PREFERENCES_NAME = "com.shounakmulay.android_telephony_plugin" + const val SHARED_PREFS_BACKGROUND_SETUP_HANDLE = "background_setup_handle" + const val SHARED_PREFS_BACKGROUND_MESSAGE_HANDLE = "background_message_handle" + const val SHARED_PREFS_DISABLE_BACKGROUND_EXE = "disable_background" + +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/shounakmulay/telephony/utils/SmsEnums.kt b/android/src/main/kotlin/com/shounakmulay/telephony/utils/SmsEnums.kt new file mode 100644 index 0000000..a1d29ce --- /dev/null +++ b/android/src/main/kotlin/com/shounakmulay/telephony/utils/SmsEnums.kt @@ -0,0 +1,94 @@ +package com.shounakmulay.telephony.utils + +import android.net.Uri +import android.provider.Telephony + +enum class SmsAction(private val methodName: String) { + GET_INBOX("getAllInboxSms"), + GET_SENT("getAllSentSms"), + GET_DRAFT("getAllDraftSms"), + GET_CONVERSATIONS("getAllConversations"), + SEND_SMS("sendSms"), + SEND_MULTIPART_SMS("sendMultipartSms"), + SEND_SMS_INTENT("sendSmsIntent"), + START_BACKGROUND_SERVICE("startBackgroundService"), + DISABLE_BACKGROUND_SERVICE("disableBackgroundService"), + BACKGROUND_SERVICE_INITIALIZED("backgroundServiceInitialized"), + IS_SMS_CAPABLE("isSmsCapable"), + GET_CELLULAR_DATA_STATE("getCellularDataState"), + GET_CALL_STATE("getCallState"), + GET_DATA_ACTIVITY("getDataActivity"), + GET_NETWORK_OPERATOR("getNetworkOperator"), + GET_NETWORK_OPERATOR_NAME("getNetworkOperatorName"), + GET_DATA_NETWORK_TYPE("getDataNetworkType"), + GET_PHONE_TYPE("getPhoneType"), + GET_SIM_OPERATOR("getSimOperator"), + GET_SIM_OPERATOR_NAME("getSimOperatorName"), + GET_SIM_STATE("getSimState"), + GET_SERVICE_STATE("getServiceState"), + GET_SIGNAL_STRENGTH("getSignalStrength"), + IS_NETWORK_ROAMING("isNetworkRoaming"), + REQUEST_SMS_PERMISSIONS("requestSmsPermissions"), + REQUEST_PHONE_PERMISSIONS("requestPhonePermissions"), + REQUEST_PHONE_AND_SMS_PERMISSIONS("requestPhoneAndSmsPermissions"), + OPEN_DIALER("openDialer"), + DIAL_PHONE_NUMBER("dialPhoneNumber"), + NO_SUCH_METHOD("noSuchMethod"); + + companion object { + fun fromMethod(method: String): SmsAction { + for (action in values()) { + if (action.methodName == method) { + return action + } + } + return NO_SUCH_METHOD + } + } + + fun toActionType(): ActionType { + return when (this) { + GET_INBOX, + GET_SENT, + GET_DRAFT, + GET_CONVERSATIONS -> ActionType.GET_SMS + SEND_SMS, + SEND_MULTIPART_SMS, + SEND_SMS_INTENT, + NO_SUCH_METHOD -> ActionType.SEND_SMS + START_BACKGROUND_SERVICE, + DISABLE_BACKGROUND_SERVICE, + BACKGROUND_SERVICE_INITIALIZED -> ActionType.BACKGROUND + IS_SMS_CAPABLE, + GET_CELLULAR_DATA_STATE, + GET_CALL_STATE, + GET_DATA_ACTIVITY, + GET_NETWORK_OPERATOR, + GET_NETWORK_OPERATOR_NAME, + GET_DATA_NETWORK_TYPE, + GET_PHONE_TYPE, + GET_SIM_OPERATOR, + GET_SIM_OPERATOR_NAME, + GET_SIM_STATE, + GET_SERVICE_STATE, + GET_SIGNAL_STRENGTH, + IS_NETWORK_ROAMING -> ActionType.GET + REQUEST_SMS_PERMISSIONS, + REQUEST_PHONE_PERMISSIONS, + REQUEST_PHONE_AND_SMS_PERMISSIONS -> ActionType.PERMISSION + OPEN_DIALER, + DIAL_PHONE_NUMBER -> ActionType.CALL + } + } +} + +enum class ActionType { + GET_SMS, SEND_SMS, BACKGROUND, GET, PERMISSION, CALL +} + +enum class ContentUri(val uri: Uri) { + INBOX(Telephony.Sms.Inbox.CONTENT_URI), + SENT(Telephony.Sms.Sent.CONTENT_URI), + DRAFT(Telephony.Sms.Draft.CONTENT_URI), + CONVERSATIONS(Telephony.Sms.Conversations.CONTENT_URI); +} \ No newline at end of file diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 0000000..4ccea8c --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,328 @@ +SF:lib\filter.dart +DA:17,1 +DA:19,1 +DA:20,2 +DA:22,1 +DA:23,1 +DA:26,1 +DA:28,1 +DA:31,1 +DA:32,3 +DA:33,3 +DA:36,1 +DA:38,1 +DA:40,1 +DA:42,1 +DA:50,1 +DA:52,1 +DA:53,2 +DA:55,1 +DA:57,1 +DA:60,1 +DA:62,1 +DA:65,1 +DA:67,3 +DA:68,3 +DA:71,1 +DA:72,1 +DA:74,1 +DA:75,1 +DA:83,1 +DA:85,1 +DA:91,1 +DA:92,1 +DA:95,1 +DA:96,1 +DA:99,0 +DA:100,0 +DA:103,1 +DA:104,1 +DA:107,1 +DA:108,1 +DA:111,1 +DA:112,1 +DA:115,1 +DA:116,1 +DA:119,0 +DA:120,0 +DA:121,0 +DA:124,1 +DA:125,1 +DA:126,1 +DA:129,1 +DA:130,2 +DA:139,2 +DA:141,1 +DA:143,1 +DA:145,1 +DA:147,1 +DA:148,4 +DA:149,2 +DA:151,4 +DA:158,2 +DA:160,1 +DA:162,1 +DA:164,1 +DA:166,1 +DA:167,4 +DA:168,2 +DA:170,4 +DA:179,1 +DA:181,1 +DA:185,6 +LF:71 +LH:66 +end_of_record +SF:lib\constants.dart +DA:78,0 +DA:86,0 +DA:101,1 +DA:102,1 +DA:108,0 +DA:116,1 +DA:117,1 +DA:133,2 +DA:134,2 +DA:135,2 +DA:136,2 +DA:137,2 +DA:138,2 +DA:139,2 +DA:140,2 +DA:143,10 +DA:145,12 +DA:147,8 +DA:149,12 +DA:151,2 +DA:152,2 +DA:153,2 +DA:154,2 +DA:155,2 +DA:156,2 +DA:157,2 +DA:158,2 +DA:159,2 +DA:160,2 +DA:161,2 +DA:162,2 +DA:163,2 +DA:164,2 +DA:165,2 +DA:166,2 +DA:167,2 +DA:168,2 +DA:169,2 +DA:170,2 +DA:171,2 +DA:172,2 +DA:175,10 +DA:177,2 +DA:178,2 +DA:179,2 +DA:180,2 +DA:181,2 +DA:182,2 +DA:183,2 +DA:184,2 +DA:185,2 +DA:186,2 +DA:187,2 +DA:188,2 +DA:189,2 +DA:192,10 +DA:194,12 +DA:196,6 +DA:199,1 +DA:201,1 +DA:212,6 +LF:61 +LH:58 +end_of_record +SF:lib\telephony.dart +DA:15,0 +DA:18,0 +DA:20,0 +DA:21,0 +DA:23,0 +DA:25,0 +DA:27,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:33,0 +DA:37,0 +DA:48,0 +DA:50,2 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:68,0 +DA:70,0 +DA:77,0 +DA:80,0 +DA:82,0 +DA:84,0 +DA:87,0 +DA:94,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:118,1 +DA:122,3 +DA:123,1 +DA:126,3 +DA:129,3 +DA:130,1 +DA:133,1 +DA:137,3 +DA:138,1 +DA:141,3 +DA:144,3 +DA:145,1 +DA:148,1 +DA:152,3 +DA:153,1 +DA:156,3 +DA:159,3 +DA:160,1 +DA:163,1 +DA:165,3 +DA:166,1 +DA:169,3 +DA:172,3 +DA:173,1 +DA:176,1 +DA:178,1 +DA:181,5 +DA:185,2 +DA:186,2 +DA:189,1 +DA:190,5 +DA:196,2 +DA:202,6 +DA:205,1 +DA:208,2 +DA:214,4 +DA:217,1 +DA:221,1 +DA:225,2 +DA:228,1 +DA:229,2 +DA:231,1 +DA:233,3 +DA:234,2 +DA:237,1 +DA:241,1 +DA:243,3 +DA:244,1 +DA:247,1 +DA:249,3 +DA:250,1 +DA:253,1 +DA:254,2 +DA:256,1 +DA:257,2 +DA:259,1 +DA:261,3 +DA:262,1 +DA:265,1 +DA:266,3 +DA:267,1 +DA:270,1 +DA:271,2 +DA:273,1 +DA:274,2 +DA:276,1 +DA:277,3 +DA:278,1 +DA:281,1 +DA:282,2 +DA:284,1 +DA:286,3 +DA:288,3 +DA:289,1 +DA:292,1 +DA:294,3 +DA:295,1 +DA:298,1 +DA:299,2 +DA:301,1 +DA:302,2 +DA:304,1 +DA:305,2 +DA:322,1 +DA:324,1 +DA:325,2 +DA:326,2 +DA:327,1 +DA:328,1 +DA:329,2 +DA:331,1 +DA:332,1 +DA:334,1 +DA:335,1 +DA:337,1 +DA:338,2 +DA:340,0 +DA:341,0 +DA:343,0 +DA:344,0 +DA:346,0 +DA:347,0 +DA:349,0 +DA:350,0 +DA:351,0 +DA:352,0 +DA:354,0 +DA:355,0 +DA:357,0 +DA:358,0 +DA:362,0 +DA:366,0 +DA:367,0 +DA:369,0 +DA:370,0 +DA:372,0 +DA:373,0 +DA:375,0 +DA:376,0 +DA:382,1 +DA:384,3 +DA:385,3 +DA:386,3 +DA:387,3 +DA:388,3 +DA:389,3 +DA:390,3 +DA:391,3 +DA:392,3 +DA:393,3 +DA:394,3 +DA:395,3 +DA:404,1 +DA:407,1 +DA:408,2 +DA:409,2 +DA:410,1 +DA:411,1 +DA:412,1 +DA:414,1 +DA:415,2 +DA:417,1 +DA:418,2 +DA:424,1 +DA:426,3 +DA:427,3 +DA:428,3 +LF:184 +LH:125 +end_of_record diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..aeb082d --- /dev/null +++ b/example/README.md @@ -0,0 +1,17 @@ + +# telephony_example + +Demonstrates how to use the telephony plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..33553a8 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,63 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 31 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + minSdkVersion 23 + applicationId "com.shounakmulay.telephony_example" + targetSdkVersion 31 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..01828ec --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4dd2e2b --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/shounakmulay/telephony_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/shounakmulay/telephony_example/MainActivity.kt new file mode 100644 index 0000000..86a83bb --- /dev/null +++ b/example/android/app/src/main/kotlin/com/shounakmulay/telephony_example/MainActivity.kt @@ -0,0 +1,14 @@ +package com.shounakmulay.telephony_example + +import android.content.IntentFilter +import android.os.Bundle +import android.os.PersistableBundle +import com.shounakmulay.telephony.sms.IncomingSmsReceiver +import io.flutter.app.FlutterApplication +import io.flutter.embedding.android.FlutterActivity + +class MainActivity :FlutterActivity() {} + +class MyApp: FlutterApplication() { + +} diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1f83a33 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..01828ec --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..ed255ee --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.6.21' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..a673820 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e190cd4 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Nov 07 10:44:31 IST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..d3b6a40 --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,15 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..404cd18 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:telephony/telephony.dart'; + +onBackgroundMessage(SmsMessage message) { + debugPrint("onBackgroundMessage called"); +} + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatefulWidget { + @override + _MyAppState createState() => _MyAppState(); +} + +class _MyAppState extends State { + String _message = ""; + final telephony = Telephony.instance; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + onMessage(SmsMessage message) async { + setState(() { + _message = message.body ?? "Error reading message body."; + }); + } + + onSendStatus(SendStatus status) { + setState(() { + _message = status == SendStatus.SENT ? "sent" : "delivered"; + }); + } + + // Platform messages are asynchronous, so we initialize in an async method. + Future initPlatformState() async { + // Platform messages may fail, so we use a try/catch PlatformException. + // If the widget was removed from the tree while the asynchronous platform + // message was in flight, we want to discard the reply rather than calling + // setState to update our non-existent appearance. + + final bool? result = await telephony.requestPhoneAndSmsPermissions; + + if (result != null && result) { + telephony.listenIncomingSms( + onNewMessage: onMessage, onBackgroundMessage: onBackgroundMessage); + } + + if (!mounted) return; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Plugin example app'), + ), + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center(child: Text("Latest received SMS: $_message")), + TextButton( + onPressed: () async { + await telephony.openDialer("123413453"); + }, + child: Text('Open Dialer')) + ], + ), + )); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..def93c5 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,75 @@ +name: telephony_example +description: Demonstrates how to use the telephony plugin. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.10.0" + +dependencies: + flutter: + sdk: flutter + + # When depending on this package from a real application you should use: + # telephony: ^x.y.z + + telephony: + # When depending on this package from a real application you should use: + # telephony: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^0.1.3 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..e6815da --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,26 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:telephony_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Text && widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/ios/Classes/SwiftTelephonyPlugin.swift b/ios/Classes/SwiftTelephonyPlugin.swift new file mode 100644 index 0000000..63f3be0 --- /dev/null +++ b/ios/Classes/SwiftTelephonyPlugin.swift @@ -0,0 +1,14 @@ +import Flutter +import UIKit + +public class SwiftTelephonyPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "telephony", binaryMessenger: registrar.messenger()) + let instance = SwiftTelephonyPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result("iOS " + UIDevice.current.systemVersion) + } +} diff --git a/ios/Classes/TelephonyPlugin.h b/ios/Classes/TelephonyPlugin.h new file mode 100644 index 0000000..a787a7f --- /dev/null +++ b/ios/Classes/TelephonyPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface TelephonyPlugin : NSObject +@end diff --git a/ios/Classes/TelephonyPlugin.m b/ios/Classes/TelephonyPlugin.m new file mode 100644 index 0000000..911ace8 --- /dev/null +++ b/ios/Classes/TelephonyPlugin.m @@ -0,0 +1,15 @@ +#import "TelephonyPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "telephony-Swift.h" +#endif + +@implementation TelephonyPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftTelephonyPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/ios/telephony.podspec b/ios/telephony.podspec new file mode 100644 index 0000000..5908274 --- /dev/null +++ b/ios/telephony.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint telephony.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'telephony' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } + s.swift_version = '5.0' +end diff --git a/lib/constants.dart b/lib/constants.dart new file mode 100644 index 0000000..31992eb --- /dev/null +++ b/lib/constants.dart @@ -0,0 +1,255 @@ +part of 'telephony.dart'; + +const _FOREGROUND_CHANNEL = 'plugins.shounakmulay.com/foreground_sms_channel'; +const _BACKGROUND_CHANNEL = 'plugins.shounakmulay.com/background_sms_channel'; + +const HANDLE_BACKGROUND_MESSAGE = "handleBackgroundMessage"; +const BACKGROUND_SERVICE_INITIALIZED = "backgroundServiceInitialized"; +const GET_ALL_INBOX_SMS = "getAllInboxSms"; +const GET_ALL_SENT_SMS = "getAllSentSms"; +const GET_ALL_DRAFT_SMS = "getAllDraftSms"; +const GET_ALL_CONVERSATIONS = "getAllConversations"; +const SEND_SMS = "sendSms"; +const SEND_MULTIPART_SMS = "sendMultipartSms"; +const SEND_SMS_INTENT = "sendSmsIntent"; +const IS_SMS_CAPABLE = "isSmsCapable"; +const GET_CELLULAR_DATA_STATE = "getCellularDataState"; +const GET_CALL_STATE = "getCallState"; +const GET_DATA_ACTIVITY = "getDataActivity"; +const GET_NETWORK_OPERATOR = "getNetworkOperator"; +const GET_NETWORK_OPERATOR_NAME = "getNetworkOperatorName"; +const GET_DATA_NETWORK_TYPE = "getDataNetworkType"; +const GET_PHONE_TYPE = "getPhoneType"; +const GET_SIM_OPERATOR = "getSimOperator"; +const GET_SIM_OPERATOR_NAME = "getSimOperatorName"; +const GET_SIM_STATE = "getSimState"; +const IS_NETWORK_ROAMING = "isNetworkRoaming"; +const GET_SIGNAL_STRENGTH = "getSignalStrength"; +const GET_SERVICE_STATE = "getServiceState"; +const REQUEST_SMS_PERMISSION = "requestSmsPermissions"; +const REQUEST_PHONE_PERMISSION = "requestPhonePermissions"; +const REQUEST_PHONE_AND_SMS_PERMISSION = "requestPhoneAndSmsPermissions"; +const OPEN_DIALER = "openDialer"; +const DIAL_PHONE_NUMBER = "dialPhoneNumber"; + +const ON_MESSAGE = "onMessage"; +const SMS_SENT = "smsSent"; +const SMS_DELIVERED = "smsDelivered"; + +/// +/// Possible parameters that can be fetched during a SMS query operation. +class _SmsProjections { +// static const String COUNT = "_count"; + static const String ID = "_id"; + static const String ORIGINATING_ADDRESS = "originating_address"; + static const String ADDRESS = "address"; + static const String MESSAGE_BODY = "message_body"; + static const String BODY = "body"; + static const String SERVICE_CENTER_ADDRESS = "service_center"; + +// static const String CREATOR = "creator"; + static const String TIMESTAMP = "timestamp"; + static const String DATE = "date"; + static const String DATE_SENT = "date_sent"; + +// static const String ERROR_CODE = "error_code"; +// static const String LOCKED = "locked"; +// static const int MESSAGE_TYPE_ALL = 0; +// static const int MESSAGE_TYPE_DRAFT = 3; +// static const int MESSAGE_TYPE_FAILED = 5; +// static const int MESSAGE_TYPE_INBOX = 1; +// static const int MESSAGE_TYPE_OUTBOX = 4; +// static const int MESSAGE_TYPE_QUEUED = 6; +// static const int MESSAGE_TYPE_SENT = 2; +// static const String PERSON = "person"; +// static const String PROTOCOL = "protocol"; + static const String READ = "read"; + +// static const String REPLY_PATH_PRESENT = "reply_path_present"; + static const String SEEN = "seen"; + +// static const String SERVICE_CENTER = "service_center"; + static const String STATUS = "status"; + +// static const int STATUS_COMPLETE = 0; +// static const int STATUS_FAILED = 64; +// static const int STATUS_NONE = -1; +// static const int STATUS_PENDING = 32; + static const String SUBJECT = "subject"; + static const String SUBSCRIPTION_ID = "sub_id"; + static const String THREAD_ID = "thread_id"; + static const String TYPE = "type"; +} + +/// +/// Possible parameters that can be fetched during a Conversation query operation. +class _ConversationProjections { + static const String SNIPPET = "snippet"; + static const String THREAD_ID = "thread_id"; + static const String MSG_COUNT = "msg_count"; +} + +abstract class _TelephonyColumn { + const _TelephonyColumn(); + + String get _name; +} + +/// Represents all the possible parameters for a SMS +class SmsColumn extends _TelephonyColumn { + final String _columnName; + + const SmsColumn._(this._columnName); + + static const ID = SmsColumn._(_SmsProjections.ID); + static const ADDRESS = SmsColumn._(_SmsProjections.ADDRESS); + static const SERVICE_CENTER_ADDRESS = + SmsColumn._(_SmsProjections.SERVICE_CENTER_ADDRESS); + static const BODY = SmsColumn._(_SmsProjections.BODY); + static const DATE = SmsColumn._(_SmsProjections.DATE); + static const DATE_SENT = SmsColumn._(_SmsProjections.DATE_SENT); + static const READ = SmsColumn._(_SmsProjections.READ); + static const SEEN = SmsColumn._(_SmsProjections.SEEN); + static const STATUS = SmsColumn._(_SmsProjections.STATUS); + static const SUBJECT = SmsColumn._(_SmsProjections.SUBJECT); + static const SUBSCRIPTION_ID = SmsColumn._(_SmsProjections.SUBSCRIPTION_ID); + static const THREAD_ID = SmsColumn._(_SmsProjections.THREAD_ID); + static const TYPE = SmsColumn._(_SmsProjections.TYPE); + + @override + String get _name => _columnName; +} + +/// Represents all the possible parameters for a Conversation +class ConversationColumn extends _TelephonyColumn { + final String _columnName; + + const ConversationColumn._(this._columnName); + + static const SNIPPET = ConversationColumn._(_ConversationProjections.SNIPPET); + static const THREAD_ID = + ConversationColumn._(_ConversationProjections.THREAD_ID); + static const MSG_COUNT = + ConversationColumn._(_ConversationProjections.MSG_COUNT); + + @override + String get _name => _columnName; +} + +const DEFAULT_SMS_COLUMNS = [ + SmsColumn.ID, + SmsColumn.ADDRESS, + SmsColumn.BODY, + SmsColumn.DATE +]; + +const INCOMING_SMS_COLUMNS = [ + SmsColumn._(_SmsProjections.ORIGINATING_ADDRESS), + SmsColumn._(_SmsProjections.MESSAGE_BODY), + SmsColumn._(_SmsProjections.TIMESTAMP), + SmsColumn._(_SmsProjections.SERVICE_CENTER_ADDRESS), + SmsColumn.STATUS +]; + +const DEFAULT_CONVERSATION_COLUMNS = [ + ConversationColumn.SNIPPET, + ConversationColumn.THREAD_ID, + ConversationColumn.MSG_COUNT +]; + +/// Represents types of SMS. +enum SmsType { + MESSAGE_TYPE_ALL, + MESSAGE_TYPE_INBOX, + MESSAGE_TYPE_SENT, + MESSAGE_TYPE_DRAFT, + MESSAGE_TYPE_OUTBOX, + MESSAGE_TYPE_FAILED, + MESSAGE_TYPE_QUEUED +} + +/// Represents states of SMS. +enum SmsStatus { STATUS_COMPLETE, STATUS_FAILED, STATUS_NONE, STATUS_PENDING } + +/// Represents data connection state. +enum DataState { DISCONNECTED, CONNECTING, CONNECTED, SUSPENDED, UNKNOWN } + +/// Represents state of cellular calls. +enum CallState { IDLE, RINGING, OFFHOOK, UNKNOWN } + +/// Represents state of cellular network data activity. +enum DataActivity { NONE, IN, OUT, INOUT, DORMANT, UNKNOWN } + +/// Represents types of networks for a device. +enum NetworkType { + UNKNOWN, + GPRS, + EDGE, + UMTS, + CDMA, + EVDO_0, + EVDO_A, + TYPE_1xRTT, + HSDPA, + HSUPA, + HSPA, + IDEN, + EVDO_B, + LTE, + EHRPD, + HSPAP, + GSM, + TD_SCDMA, + IWLAN, + LTE_CA, + NR, +} + +/// Represents types of cellular technology supported by a device. +enum PhoneType { NONE, GSM, CDMA, SIP, UNKNOWN } + +/// Represents state of SIM. +enum SimState { + UNKNOWN, + ABSENT, + PIN_REQUIRED, + PUK_REQUIRED, + NETWORK_LOCKED, + READY, + NOT_READY, + PERM_DISABLED, + CARD_IO_ERROR, + CARD_RESTRICTED, + LOADED, + PRESENT +} + +/// Represents state of cellular service. +enum ServiceState { + IN_SERVICE, + OUT_OF_SERVICE, + EMERGENCY_ONLY, + POWER_OFF, + UNKNOWN +} + +/// Represents the quality of cellular signal. +enum SignalStrength { NONE_OR_UNKNOWN, POOR, MODERATE, GOOD, GREAT } + +/// Represents sort order for [OrderBy]. +enum Sort { ASC, DESC } + +extension Value on Sort { + String get value { + switch (this) { + case Sort.ASC: + return "ASC"; + case Sort.DESC: + default: + return "DESC"; + } + } +} + +/// Represents the status of a sms message sent from the device. +enum SendStatus { SENT, DELIVERED } diff --git a/lib/filter.dart b/lib/filter.dart new file mode 100644 index 0000000..5a1a96f --- /dev/null +++ b/lib/filter.dart @@ -0,0 +1,200 @@ +part of 'telephony.dart'; + +abstract class Filter { + T and(K column); + + T or(K column); + + String get selection; + + List get selectionArgs; +} + +/// Filter to be applied to a SMS query operation. +/// +/// Works like a SQL WHERE clause. +/// +/// Public constructor: +/// +/// SmsFilter.where(); +class SmsFilter implements Filter { + final String _filter; + final List _filterArgs; + + SmsFilter._(this._filter, this._filterArgs); + + static SmsFilterStatement where(SmsColumn column) => + SmsFilterStatement._(column._columnName); + + /// Joins two filter statements by the AND operator. + SmsFilterStatement and(SmsColumn column) { + return _addCombineOperator(column, " AND"); + } + + /// Joins to filter statements by the OR operator. + @override + SmsFilterStatement or(SmsColumn column) { + return _addCombineOperator(column, " OR"); + } + + SmsFilterStatement _addCombineOperator(SmsColumn column, String operator) { + return SmsFilterStatement._withPreviousFilter("$_filter $operator", + column._name, List.from(_filterArgs, growable: true)); + } + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + @override + String get selection => _filter; + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + @override + List get selectionArgs => _filterArgs; +} + +class ConversationFilter + extends Filter { + final String _filter; + final List _filterArgs; + + ConversationFilter._(this._filter, this._filterArgs); + + static ConversationFilterStatement where(ConversationColumn column) => + ConversationFilterStatement._(column._columnName); + + /// Joins two filter statements by the AND operator. + @override + ConversationFilterStatement and(ConversationColumn column) { + return _addCombineOperator(column, " AND"); + } + + /// Joins to filter statements by the OR operator. + @override + ConversationFilterStatement or(ConversationColumn column) { + return _addCombineOperator(column, " OR"); + } + + ConversationFilterStatement _addCombineOperator( + ConversationColumn column, String operator) { + return ConversationFilterStatement._withPreviousFilter("$_filter $operator", + column._name, List.from(_filterArgs, growable: true)); + } + + @override + String get selection => _filter; + + @override + List get selectionArgs => _filterArgs; +} + +abstract class FilterStatement { + String _column; + String _previousFilter = ""; + List _previousFilterArgs = []; + + FilterStatement._(this._column); + + FilterStatement._withPreviousFilter( + String previousFilter, String column, List previousFilterArgs) + : _previousFilter = previousFilter, + _column = column, + _previousFilterArgs = previousFilterArgs; + + /// Checks equality between the column value and [equalTo] value + T equals(String equalTo) { + return _createFilter(equalTo, "="); + } + + /// Checks whether the value of the column is greater than [value] + T greaterThan(String value) { + return _createFilter(value, ">"); + } + + /// Checks whether the value of the column is less than [value] + T lessThan(String value) { + return _createFilter(value, "<"); + } + + /// Checks whether the value of the column is greater than or equal to [value] + T greaterThanOrEqualTo(String value) { + return _createFilter(value, ">="); + } + + /// Checks whether the value of the column is less than or equal to [value] + T lessThanOrEqualTo(String value) { + return _createFilter(value, "<="); + } + + /// Checks for inequality between the column value and [value] + T notEqualTo(String value) { + return _createFilter(value, "!="); + } + + /// Checks whether the column value is LIKE the provided string [value] + T like(String value) { + return _createFilter(value, "LIKE"); + } + + /// Checks whether the column value is in the provided list of [values] + T inValues(List values) { + final String filterValues = values.join(","); + return _createFilter("($filterValues)", "IN"); + } + + /// Checks whether the column value lies BETWEEN [from] and [to]. + T between(String from, String to) { + final String filterValue = "$from AND $to"; + return _createFilter(filterValue, "BETWEEN"); + } + + /// Applies the NOT operator + K get not { + _previousFilter += " NOT"; + return this as K; + } + + T _createFilter(String value, String operator); +} + +class SmsFilterStatement + extends FilterStatement { + SmsFilterStatement._(String column) : super._(column); + + SmsFilterStatement._withPreviousFilter( + String previousFilter, String column, List previousFilterArgs) + : super._withPreviousFilter(previousFilter, column, previousFilterArgs); + + @override + SmsFilter _createFilter(String value, String operator) { + return SmsFilter._("$_previousFilter $_column $operator ?", + _previousFilterArgs..add(value)); + } +} + +class ConversationFilterStatement + extends FilterStatement { + ConversationFilterStatement._(String column) : super._(column); + + ConversationFilterStatement._withPreviousFilter( + String previousFilter, String column, List previousFilterArgs) + : super._withPreviousFilter(previousFilter, column, previousFilterArgs); + + @override + ConversationFilter _createFilter(String value, String operator) { + return ConversationFilter._("$_previousFilter $_column $operator ?", + _previousFilterArgs..add(value)); + } +} + +class OrderBy { + final _TelephonyColumn _column; + Sort _sort = Sort.DESC; + + /// Orders the query results by the provided column and [sort] value. + OrderBy(this._column, {Sort sort = Sort.DESC}) { + _sort = sort; + } + + String get _value => "${_column._name} ${_sort.value}"; +} diff --git a/lib/telephony.dart b/lib/telephony.dart new file mode 100644 index 0000000..d15eed6 --- /dev/null +++ b/lib/telephony.dart @@ -0,0 +1,702 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:platform/platform.dart'; + +part 'constants.dart'; + +part 'filter.dart'; + +typedef MessageHandler(SmsMessage message); +typedef SmsSendStatusListener(SendStatus status); + +void _flutterSmsSetupBackgroundChannel( + {MethodChannel backgroundChannel = + const MethodChannel(_BACKGROUND_CHANNEL)}) async { + WidgetsFlutterBinding.ensureInitialized(); + + backgroundChannel.setMethodCallHandler((call) async { + if (call.method == HANDLE_BACKGROUND_MESSAGE) { + final CallbackHandle handle = + CallbackHandle.fromRawHandle(call.arguments['handle']); + final Function handlerFunction = + PluginUtilities.getCallbackFromHandle(handle)!; + try { + await handlerFunction(SmsMessage.fromMap( + call.arguments['message'], INCOMING_SMS_COLUMNS)); + } catch (e) { + print('Unable to handle incoming background message.'); + print(e); + } + return Future.value(); + } + }); + + backgroundChannel.invokeMethod(BACKGROUND_SERVICE_INITIALIZED); +} + +/// +/// A Flutter plugin to use telephony features such as +/// - Send SMS Messages +/// - Query SMS Messages +/// - Listen for incoming SMS +/// - Retrieve various network parameters +/// +/// +/// This plugin tries to replicate some of the functionality provided by Android's Telephony class. +/// +/// +class Telephony { + final MethodChannel _foregroundChannel; + final Platform _platform; + + late MessageHandler _onNewMessage; + late MessageHandler _onBackgroundMessages; + late SmsSendStatusListener _statusListener; + + /// + /// Gets a singleton instance of the [Telephony] class. + /// + static Telephony get instance => _instance; + + /// + /// Gets a singleton instance of the [Telephony] class to be used in background execution context. + /// + static Telephony get backgroundInstance => _backgroundInstance; + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + Telephony.private(MethodChannel methodChannel, Platform platform) + : _foregroundChannel = methodChannel, + _platform = platform; + + Telephony._newInstance(MethodChannel methodChannel, LocalPlatform platform) + : _foregroundChannel = methodChannel, + _platform = platform { + _foregroundChannel.setMethodCallHandler(handler); + } + + static final Telephony _instance = Telephony._newInstance( + const MethodChannel(_FOREGROUND_CHANNEL), const LocalPlatform()); + static final Telephony _backgroundInstance = Telephony._newInstance( + const MethodChannel(_FOREGROUND_CHANNEL), const LocalPlatform()); + + /// + /// Listens to incoming SMS. + /// + /// ### Requires RECEIVE_SMS permission. + /// + /// Parameters: + /// + /// - [onNewMessage] : Called on every new message received when app is in foreground. + /// - [onBackgroundMessage] (optional) : Called on every new message received when app is in background. + /// - [listenInBackground] (optional) : Defaults to true. Set to false to only listen to messages in foreground. [listenInBackground] is + /// ignored if [onBackgroundMessage] is not set. + /// + /// + void listenIncomingSms( + {required MessageHandler onNewMessage, + MessageHandler? onBackgroundMessage, + bool listenInBackground = true}) { + assert(_platform.isAndroid == true, "Can only be called on Android."); + assert( + listenInBackground + ? onBackgroundMessage != null + : onBackgroundMessage == null, + listenInBackground + ? "`onBackgroundMessage` cannot be null when `listenInBackground` is true. Set `listenInBackground` to false if you don't need background processing." + : "You have set `listenInBackground` to false. `onBackgroundMessage` can only be set when `listenInBackground` is true"); + + _onNewMessage = onNewMessage; + + if (listenInBackground && onBackgroundMessage != null) { + _onBackgroundMessages = onBackgroundMessage; + final CallbackHandle backgroundSetupHandle = + PluginUtilities.getCallbackHandle(_flutterSmsSetupBackgroundChannel)!; + final CallbackHandle? backgroundMessageHandle = + PluginUtilities.getCallbackHandle(_onBackgroundMessages); + + if (backgroundMessageHandle == null) { + throw ArgumentError( + '''Failed to setup background message handler! `onBackgroundMessage` + should be a TOP-LEVEL OR STATIC FUNCTION and should NOT be tied to a + class or an anonymous function.''', + ); + } + + _foregroundChannel.invokeMethod( + 'startBackgroundService', + { + 'setupHandle': backgroundSetupHandle.toRawHandle(), + 'backgroundHandle': backgroundMessageHandle.toRawHandle() + }, + ); + } else { + _foregroundChannel.invokeMethod('disableBackgroundService'); + } + } + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + Future handler(MethodCall call) async { + switch (call.method) { + case ON_MESSAGE: + final message = call.arguments["message"]; + return _onNewMessage(SmsMessage.fromMap(message, INCOMING_SMS_COLUMNS)); + case SMS_SENT: + return _statusListener(SendStatus.SENT); + case SMS_DELIVERED: + return _statusListener(SendStatus.DELIVERED); + } + } + + /// + /// Query SMS Inbox. + /// + /// ### Requires READ_SMS permission. + /// + /// Parameters: + /// + /// - [columns] (optional) : List of [SmsColumn] to be returned by this query. Defaults to [ SmsColumn.ID, SmsColumn.ADDRESS, SmsColumn.BODY, SmsColumn.DATE ] + /// - [filter] (optional) : [SmsFilter] to filter the results of this query. Works like SQL WHERE clause. + /// - [sortOrder] (optional): List of [OrderBy]. Orders the results of this query by the provided columns and order. + /// + /// Returns: + /// + /// [Future>] + Future> getInboxSms( + {List columns = DEFAULT_SMS_COLUMNS, + SmsFilter? filter, + List? sortOrder}) async { + assert(_platform.isAndroid == true, "Can only be called on Android."); + final args = _getArguments(columns, filter, sortOrder); + + final messages = + await _foregroundChannel.invokeMethod(GET_ALL_INBOX_SMS, args); + + return messages + ?.map((message) => SmsMessage.fromMap(message, columns)) + .toList(growable: false) ?? + List.empty(); + } + + /// + /// Query SMS Outbox / Sent messages. + /// + /// ### Requires READ_SMS permission. + /// + /// Parameters: + /// + /// - [columns] (optional) : List of [SmsColumn] to be returned by this query. Defaults to [ SmsColumn.ID, SmsColumn.ADDRESS, SmsColumn.BODY, SmsColumn.DATE ] + /// - [filter] (optional) : [SmsFilter] to filter the results of this query. Works like SQL WHERE clause. + /// - [sortOrder] (optional): List of [OrderBy]. Orders the results of this query by the provided columns and order. + /// + /// Returns: + /// + /// [Future>] + Future> getSentSms( + {List columns = DEFAULT_SMS_COLUMNS, + SmsFilter? filter, + List? sortOrder}) async { + assert(_platform.isAndroid == true, "Can only be called on Android."); + final args = _getArguments(columns, filter, sortOrder); + + final messages = + await _foregroundChannel.invokeMethod(GET_ALL_SENT_SMS, args); + + return messages + ?.map((message) => SmsMessage.fromMap(message, columns)) + .toList(growable: false) ?? + List.empty(); + } + + /// + /// Query SMS Drafts. + /// + /// ### Requires READ_SMS permission. + /// + /// Parameters: + /// + /// - [columns] (optional) : List of [SmsColumn] to be returned by this query. Defaults to [ SmsColumn.ID, SmsColumn.ADDRESS, SmsColumn.BODY, SmsColumn.DATE ] + /// - [filter] (optional) : [SmsFilter] to filter the results of this query. Works like SQL WHERE clause. + /// - [sortOrder] (optional): List of [OrderBy]. Orders the results of this query by the provided columns and order. + /// + /// Returns: + /// + /// [Future>] + Future> getDraftSms( + {List columns = DEFAULT_SMS_COLUMNS, + SmsFilter? filter, + List? sortOrder}) async { + assert(_platform.isAndroid == true, "Can only be called on Android."); + final args = _getArguments(columns, filter, sortOrder); + + final messages = + await _foregroundChannel.invokeMethod(GET_ALL_DRAFT_SMS, args); + + return messages + ?.map((message) => SmsMessage.fromMap(message, columns)) + .toList(growable: false) ?? + List.empty(); + } + + /// + /// Query SMS Inbox. + /// + /// ### Requires READ_SMS permission. + /// + /// Parameters: + /// + /// - [filter] (optional) : [ConversationFilter] to filter the results of this query. Works like SQL WHERE clause. + /// - [sortOrder] (optional): List of [OrderBy]. Orders the results of this query by the provided columns and order. + /// + /// Returns: + /// + /// [Future>] + Future> getConversations( + {ConversationFilter? filter, List? sortOrder}) async { + assert(_platform.isAndroid == true, "Can only be called on Android."); + final args = _getArguments(DEFAULT_CONVERSATION_COLUMNS, filter, sortOrder); + + final conversations = await _foregroundChannel.invokeMethod( + GET_ALL_CONVERSATIONS, args); + + return conversations + ?.map((conversation) => SmsConversation.fromMap(conversation)) + .toList(growable: false) ?? + List.empty(); + } + + Map _getArguments(List<_TelephonyColumn> columns, + Filter? filter, List? sortOrder) { + final Map args = {}; + + args["projection"] = columns.map((c) => c._name).toList(); + + if (filter != null) { + args["selection"] = filter.selection; + args["selection_args"] = filter.selectionArgs; + } + + if (sortOrder != null && sortOrder.isNotEmpty) { + args["sort_order"] = sortOrder.map((o) => o._value).join(","); + } + + return args; + } + + /// + /// Send an SMS directly from your application. Uses Android's SmsManager to send SMS. + /// + /// ### Requires SEND_SMS permission. + /// + /// Parameters: + /// + /// - [to] : Address to send the SMS to. + /// - [message] : Message to be sent. If message body is longer than standard SMS length limits set appropriate + /// value for [isMultipart] + /// - [statusListener] (optional) : Listen to the status of the sent SMS. Values can be one of [SmsStatus] + /// - [isMultipart] (optional) : If message body is longer than standard SMS limit of 160 characters, set this flag to + /// send the SMS in multiple parts. + Future sendSms({ + required String to, + required String message, + SmsSendStatusListener? statusListener, + bool isMultipart = false, + }) async { + assert(_platform.isAndroid == true, "Can only be called on Android."); + bool listenStatus = false; + if (statusListener != null) { + _statusListener = statusListener; + listenStatus = true; + } + final Map args = { + "address": to, + "message_body": message, + "listen_status": listenStatus + }; + final String method = isMultipart ? SEND_MULTIPART_SMS : SEND_SMS; + await _foregroundChannel.invokeMethod(method, args); + } + + /// + /// Open Android's default SMS application with the provided message and address. + /// + /// ### Requires SEND_SMS permission. + /// + /// Parameters: + /// + /// - [to] : Address to send the SMS to. + /// - [message] : Message to be sent. + /// + Future sendSmsByDefaultApp({ + required String to, + required String message, + }) async { + final Map args = { + "address": to, + "message_body": message, + }; + await _foregroundChannel.invokeMethod(SEND_SMS_INTENT, args); + } + + /// + /// Checks if the device has necessary features to send and receive SMS. + /// + /// Uses TelephonyManager class on Android. + /// + Future get isSmsCapable => + _foregroundChannel.invokeMethod(IS_SMS_CAPABLE); + + /// + /// Returns a constant indicating the current data connection state (cellular). + /// + /// Returns: + /// + /// [Future] + Future get cellularDataState async { + final int? dataState = + await _foregroundChannel.invokeMethod(GET_CELLULAR_DATA_STATE); + if (dataState == null || dataState == -1) { + return DataState.UNKNOWN; + } else { + return DataState.values[dataState]; + } + } + + /// + /// Returns a constant that represents the current state of all phone calls. + /// + /// Returns: + /// + /// [Future] + Future get callState async { + final int? state = + await _foregroundChannel.invokeMethod(GET_CALL_STATE); + if (state != null) { + return CallState.values[state]; + } else { + return CallState.UNKNOWN; + } + } + + /// + /// Returns a constant that represents the current state of all phone calls. + /// + /// Returns: + /// + /// [Future] + Future get dataActivity async { + final int? activity = + await _foregroundChannel.invokeMethod(GET_DATA_ACTIVITY); + if (activity != null) { + return DataActivity.values[activity]; + } else { + return DataActivity.UNKNOWN; + } + } + + /// + /// Returns the numeric name (MCC+MNC) of current registered operator. + /// + /// Availability: Only when user is registered to a network. + /// + /// Result may be unreliable on CDMA networks (use phoneType to determine if on a CDMA network). + /// + Future get networkOperator => + _foregroundChannel.invokeMethod(GET_NETWORK_OPERATOR); + + /// + /// Returns the alphabetic name of current registered operator. + /// + /// Availability: Only when user is registered to a network. + /// + /// Result may be unreliable on CDMA networks (use phoneType to determine if on a CDMA network). + /// + Future get networkOperatorName => + _foregroundChannel.invokeMethod(GET_NETWORK_OPERATOR_NAME); + + /// + /// Returns a constant indicating the radio technology (network type) currently in use on the device for data transmission. + /// + /// ### Requires READ_PHONE_STATE permission. + /// + Future get dataNetworkType async { + final int? type = + await _foregroundChannel.invokeMethod(GET_DATA_NETWORK_TYPE); + if (type != null) { + return NetworkType.values[type]; + } else { + return NetworkType.UNKNOWN; + } + } + + /// + /// Returns a constant indicating the device phone type. This indicates the type of radio used to transmit voice calls. + /// + Future get phoneType async { + final int? type = + await _foregroundChannel.invokeMethod(GET_PHONE_TYPE); + if (type != null) { + return PhoneType.values[type]; + } else { + return PhoneType.UNKNOWN; + } + } + + /// + /// Returns the MCC+MNC (mobile country code + mobile network code) of the provider of the SIM. 5 or 6 decimal digits. + /// + /// Availability: SimState must be SIM\_STATE\_READY + Future get simOperator => + _foregroundChannel.invokeMethod(GET_SIM_OPERATOR); + + /// + /// Returns the Service Provider Name (SPN). + /// + /// Availability: SimState must be SIM_STATE_READY + Future get simOperatorName => + _foregroundChannel.invokeMethod(GET_SIM_OPERATOR_NAME); + + /// + /// Returns a constant indicating the state of the default SIM card. + /// + /// Returns: + /// + /// [Future] + Future get simState async { + final int? state = + await _foregroundChannel.invokeMethod(GET_SIM_STATE); + if (state != null) { + return SimState.values[state]; + } else { + return SimState.UNKNOWN; + } + } + + /// + /// Returns true if the device is considered roaming on the current network, for GSM purposes. + /// + /// Availability: Only when user registered to a network. + Future get isNetworkRoaming => + _foregroundChannel.invokeMethod(IS_NETWORK_ROAMING); + + /// + /// Returns a List of SignalStrength or an empty List if there are no valid measurements. + /// + /// ### Requires Android build version 29 --> Android Q + /// + /// Returns: + /// + /// [Future>] + Future> get signalStrengths async { + final List? strengths = + await _foregroundChannel.invokeMethod(GET_SIGNAL_STRENGTH); + return (strengths ?? []) + .map((s) => SignalStrength.values[s]) + .toList(growable: false); + } + + /// + /// Returns current voice service state. + /// + /// ### Requires Android build version 26 --> Android O + /// ### Requires permissions ACCESS_COARSE_LOCATION and READ_PHONE_STATE + /// + /// Returns: + /// + /// [Future] + Future get serviceState async { + final int? state = + await _foregroundChannel.invokeMethod(GET_SERVICE_STATE); + if (state != null) { + return ServiceState.values[state]; + } else { + return ServiceState.UNKNOWN; + } + } + + /// + /// Request the user for all the sms permissions listed in the app's AndroidManifest.xml + /// + Future get requestSmsPermissions => + _foregroundChannel.invokeMethod(REQUEST_SMS_PERMISSION); + + /// + /// Request the user for all the phone permissions listed in the app's AndroidManifest.xml + /// + Future get requestPhonePermissions => + _foregroundChannel.invokeMethod(REQUEST_PHONE_PERMISSION); + + /// + /// Request the user for all the phone and sms permissions listed in the app's AndroidManifest.xml + /// + Future get requestPhoneAndSmsPermissions => + _foregroundChannel.invokeMethod(REQUEST_PHONE_AND_SMS_PERMISSION); + + /// + /// Opens the default dialer with the given phone number. + /// + Future openDialer(String phoneNumber) async { + assert(phoneNumber.isNotEmpty, "phoneNumber cannot be empty"); + final Map args = {"phoneNumber": phoneNumber}; + await _foregroundChannel.invokeMethod(OPEN_DIALER, args); + } + + /// + /// Starts a phone all with the given phone number. + /// + /// ### Requires permission CALL_PHONE + /// + Future dialPhoneNumber(String phoneNumber) async { + assert(phoneNumber.isNotEmpty, "phoneNumber cannot be null or empty"); + final Map args = {"phoneNumber": phoneNumber}; + await _foregroundChannel.invokeMethod(DIAL_PHONE_NUMBER, args); + } +} + +/// +/// Represents a message returned by one of the query functions such as +/// [getInboxSms], [getSentSms], [getDraftSms] +class SmsMessage { + int? id; + String? address; + String? body; + int? date; + int? dateSent; + bool? read; + bool? seen; + String? subject; + int? subscriptionId; + int? threadId; + SmsType? type; + SmsStatus? status; + String? serviceCenterAddress; + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + SmsMessage.fromMap(Map rawMessage, List columns) { + final message = Map.castFrom(rawMessage); + for (var column in columns) { + debugPrint('Column is ${column._columnName}'); + final value = message[column._columnName]; + switch (column._columnName) { + case _SmsProjections.ID: + this.id = int.tryParse(value); + break; + case _SmsProjections.ORIGINATING_ADDRESS: + case _SmsProjections.ADDRESS: + this.address = value; + break; + case _SmsProjections.MESSAGE_BODY: + case _SmsProjections.BODY: + this.body = value; + break; + case _SmsProjections.DATE: + case _SmsProjections.TIMESTAMP: + this.date = int.tryParse(value); + break; + case _SmsProjections.DATE_SENT: + this.dateSent = int.tryParse(value); + break; + case _SmsProjections.READ: + this.read = int.tryParse(value) == 0 ? false : true; + break; + case _SmsProjections.SEEN: + this.seen = int.tryParse(value) == 0 ? false : true; + break; + case _SmsProjections.STATUS: + switch (int.tryParse(value)) { + case 0: + this.status = SmsStatus.STATUS_COMPLETE; + break; + case 32: + this.status = SmsStatus.STATUS_PENDING; + break; + case 64: + this.status = SmsStatus.STATUS_FAILED; + break; + case -1: + default: + this.status = SmsStatus.STATUS_NONE; + break; + } + break; + case _SmsProjections.SUBJECT: + this.subject = value; + break; + case _SmsProjections.SUBSCRIPTION_ID: + this.subscriptionId = int.tryParse(value); + break; + case _SmsProjections.THREAD_ID: + this.threadId = int.tryParse(value); + break; + case _SmsProjections.TYPE: + var smsTypeIndex = int.tryParse(value); + this.type = + smsTypeIndex != null ? SmsType.values[smsTypeIndex] : null; + break; + case _SmsProjections.SERVICE_CENTER_ADDRESS: + this.serviceCenterAddress = value; + break; + } + } + } + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + bool equals(SmsMessage other) { + return this.id == other.id && + this.address == other.address && + this.body == other.body && + this.date == other.date && + this.dateSent == other.dateSent && + this.read == other.read && + this.seen == other.seen && + this.subject == other.subject && + this.subscriptionId == other.subscriptionId && + this.threadId == other.threadId && + this.type == other.type && + this.status == other.status; + } +} + +/// +/// Represents a conversation returned by the query conversation functions +/// [getConversations] +class SmsConversation { + String? snippet; + int? threadId; + int? messageCount; + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + SmsConversation.fromMap(Map rawConversation) { + final conversation = + Map.castFrom(rawConversation); + for (var column in DEFAULT_CONVERSATION_COLUMNS) { + final String? value = conversation[column._columnName]; + switch (column._columnName) { + case _ConversationProjections.SNIPPET: + this.snippet = value; + break; + case _ConversationProjections.THREAD_ID: + this.threadId = int.tryParse(value!); + break; + case _ConversationProjections.MSG_COUNT: + this.messageCount = int.tryParse(value!); + break; + } + } + } + + /// ## Do not call this method. This method is visible only for testing. + @visibleForTesting + bool equals(SmsConversation other) { + return this.threadId == other.threadId && + this.snippet == other.snippet && + this.messageCount == other.messageCount; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..06540cf --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,66 @@ +name: telephony +description: A Flutter plugin to use telephony features such as fetch network + info, start phone calls, send and receive SMS, and listen for incoming SMS. +version: 0.2.0 +homepage: https://telephony.shounakmulay.dev +repository: https://github.com/shounakmulay/Telephony + +environment: + sdk: '>=2.15.1 <3.0.0' + flutter: ">=1.10.0" + +dependencies: + flutter: + sdk: flutter + platform: ^3.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.1.10 + collection: ^1.16.0 + mockito: ^5.2.0 +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec +# The following section is specific to Flutter. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' and Android 'package' identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.shounakmulay.telephony + pluginClass: TelephonyPlugin + ios: + pluginClass: TelephonyPlugin + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/test/listener_test.dart b/test/listener_test.dart new file mode 100644 index 0000000..0298d60 --- /dev/null +++ b/test/listener_test.dart @@ -0,0 +1,66 @@ +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:platform/platform.dart"; +import "package:telephony/telephony.dart"; +import 'mocks/messages.dart'; + +main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MethodChannel methodChannel; + late Telephony telephony; + final List log = []; + SmsSendStatusListener listener; + + setUp(() { + methodChannel = MethodChannel("testChannel"); + telephony = Telephony.private( + methodChannel, FakePlatform(operatingSystem: "android")); + methodChannel.setMockMethodCallHandler((call) { + log.add(call); + return telephony.handler(call); + }); + }); + + tearDown(() { + methodChannel.setMockMethodCallHandler(null); + log.clear(); + }); + + group("should listen to", () { + test("sms sent status", () async { + listener = (status) { + expect(status, SendStatus.SENT); + }; + + final args = { + "address": "0000000000", + "message_body": "Test message", + "listen_status": true + }; + + telephony.sendSms( + to: "0000000000", message: "Test message", statusListener: listener); + + expect(log, [isMethodCall(SEND_SMS, arguments: args)]); + + // called by native side + methodChannel.invokeMethod(SMS_SENT); + + expect(log.length, 2); + expect(log.last, isMethodCall(SMS_SENT, arguments: null)); + }); + + test("incoming sms", () async { + telephony.listenIncomingSms( + onNewMessage: (message) { + expect(message.body, mockIncomingMessage["message_body"]); + expect(message.address, mockIncomingMessage["originating_address"]); + expect(message.status, SmsStatus.STATUS_COMPLETE); + }, + listenInBackground: false); + + methodChannel.invokeMethod(ON_MESSAGE, {"message": mockIncomingMessage}); + }); + }); +} diff --git a/test/mocks/messages.dart b/test/mocks/messages.dart new file mode 100644 index 0000000..8016e13 --- /dev/null +++ b/test/mocks/messages.dart @@ -0,0 +1,50 @@ +import 'dart:collection'; + +final mockMessages = [ + LinkedHashMap.from({ + "_id": "1", + "address": "123456", + "body": "message body", + "date": "1595056125597", + "thread_id": "3" + }), + LinkedHashMap.from({ + "_id": "12", + "address": "0000000000", + "body": "text message", + "date": "1595056125663", + "thread_id": "6" + }) +]; + +final mockMessageWithSmsType = LinkedHashMap.from({ + "_id": "1", + "address": "123456", + "body": "message body", + "date": "1595056125597", + "thread_id": "3", + "type": "1" + }); + + final mockMessageWithInvalidSmsType = LinkedHashMap.from({ + "_id": "1", + "address": "123456", + "body": "message body", + "date": "1595056125597", + "thread_id": "3", + "type": "type" + }); + +final mockConversations = [ + LinkedHashMap.from( + {"snippet": "message snippet", "thread_id": "2", "msg_count": "32"}), + LinkedHashMap.from( + {"snippet": "snippet", "thread_id": "5", "msg_count": "20"}) +]; + +const mockIncomingMessage = { + "originating_address": "123456789", + "message_body": "incoming sms", + "timestamp": "123422135", + "status": "0" +}; diff --git a/test/telephony_test.dart b/test/telephony_test.dart new file mode 100644 index 0000000..3c10dce --- /dev/null +++ b/test/telephony_test.dart @@ -0,0 +1,522 @@ +import 'dart:collection'; + +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import 'package:mockito/annotations.dart'; +import "package:mockito/mockito.dart"; +import "package:platform/platform.dart"; +import "package:telephony/telephony.dart"; +import 'package:collection/collection.dart'; + +import 'mocks/messages.dart'; +import 'telephony_test.mocks.dart'; + +@GenerateMocks([MethodChannel]) +main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MockMethodChannel methodChannel = MockMethodChannel(); + late Telephony telephony; + + setUp(() { + methodChannel = MockMethodChannel(); + telephony = Telephony.private( + methodChannel, FakePlatform(operatingSystem: "android")); + }); + + tearDown(() { + verifyNoMoreInteractions(methodChannel); + }); + + group('telephony', () { + group("should request", () { + test("sms permissions", () async { + when(methodChannel.invokeMethod(REQUEST_SMS_PERMISSION)) + .thenAnswer((_) => Future.value(true)); + final permissionGranted = await telephony.requestSmsPermissions; + verify(methodChannel.invokeMethod(REQUEST_SMS_PERMISSION)) + .called(1); + expect(permissionGranted, true); + }); + + test("phone permissions", () async { + when(methodChannel.invokeMethod(REQUEST_PHONE_PERMISSION)) + .thenAnswer((_) => Future.value(true)); + final permissionGranted = await telephony.requestPhonePermissions; + verify(methodChannel.invokeMethod(REQUEST_PHONE_PERMISSION)) + .called(1); + expect(permissionGranted, true); + }); + + test("phone and sms permissions", () async { + when(methodChannel.invokeMethod(REQUEST_PHONE_AND_SMS_PERMISSION)) + .thenAnswer((_) => Future.value(true)); + final permissionGranted = await telephony.requestPhoneAndSmsPermissions; + verify(methodChannel + .invokeMethod(REQUEST_PHONE_AND_SMS_PERMISSION)) + .called(1); + expect(permissionGranted, true); + }); + }); + + group("should get", () { + test("service state", () async { + when(methodChannel.invokeMethod(GET_SERVICE_STATE)) + .thenAnswer((_) => Future.value(1)); + final state = await telephony.serviceState; + verify(methodChannel.invokeMethod(GET_SERVICE_STATE)).called(1); + expect(state, ServiceState.OUT_OF_SERVICE); + }); + + test("signal strengths", () async { + when(methodChannel.invokeMethod(GET_SIGNAL_STRENGTH)) + .thenAnswer((_) => Future.value([0, 1])); + final strengths = await telephony.signalStrengths; + verify(methodChannel.invokeMethod(GET_SIGNAL_STRENGTH)).called(1); + expect( + strengths, [SignalStrength.NONE_OR_UNKNOWN, SignalStrength.POOR]); + }); + + test("is network roaming", () async { + when(methodChannel.invokeMethod(IS_NETWORK_ROAMING)) + .thenAnswer((_) => Future.value(false)); + final result = await telephony.isNetworkRoaming; + verify(methodChannel.invokeMethod(IS_NETWORK_ROAMING)).called(1); + expect(result, false); + }); + + test("sim state", () async { + when(methodChannel.invokeMethod(GET_SIM_STATE)) + .thenAnswer((_) => Future.value(5)); + final state = await telephony.simState; + verify(methodChannel.invokeMethod(GET_SIM_STATE)).called(1); + expect(state, SimState.READY); + }); + + test("sim operator name", () async { + when(methodChannel.invokeMethod(GET_SIM_OPERATOR_NAME)) + .thenAnswer((_) => Future.value("operatorName")); + final name = await telephony.simOperatorName; + verify(methodChannel.invokeMethod(GET_SIM_OPERATOR_NAME)).called(1); + expect(name, "operatorName"); + }); + + test("sim operator", () async { + when(methodChannel.invokeMethod(GET_SIM_OPERATOR)) + .thenAnswer((_) => Future.value("operator")); + final name = await telephony.simOperator; + verify(methodChannel.invokeMethod(GET_SIM_OPERATOR)).called(1); + expect(name, "operator"); + }); + + test("phone type", () async { + when(methodChannel.invokeMethod(GET_PHONE_TYPE)) + .thenAnswer((_) => Future.value(2)); + final type = await telephony.phoneType; + verify(methodChannel.invokeMethod(GET_PHONE_TYPE)).called(1); + expect(type, PhoneType.CDMA); + }); + + test("data network type", () async { + when(methodChannel.invokeMethod(GET_DATA_NETWORK_TYPE)) + .thenAnswer((_) => Future.value(0)); + final type = await telephony.dataNetworkType; + verify(methodChannel.invokeMethod(GET_DATA_NETWORK_TYPE)).called(1); + expect(type, NetworkType.UNKNOWN); + }); + + test("network operator name", () async { + when(methodChannel.invokeMethod(GET_NETWORK_OPERATOR_NAME)) + .thenAnswer((_) => Future.value("operator name")); + final name = await telephony.networkOperatorName; + verify(methodChannel.invokeMethod(GET_NETWORK_OPERATOR_NAME)).called(1); + expect(name, "operator name"); + }); + + test("network operator", () async { + when(methodChannel.invokeMethod(GET_NETWORK_OPERATOR)) + .thenAnswer((_) => Future.value("operator")); + final operator = await telephony.networkOperator; + verify(methodChannel.invokeMethod(GET_NETWORK_OPERATOR)).called(1); + expect(operator, "operator"); + }); + + test("data activity", () async { + when(methodChannel.invokeMethod(GET_DATA_ACTIVITY)) + .thenAnswer((_) => Future.value(1)); + final activity = await telephony.dataActivity; + verify(methodChannel.invokeMethod(GET_DATA_ACTIVITY)).called(1); + expect(activity, DataActivity.IN); + }); + + test("call state", () async { + when(methodChannel.invokeMethod(GET_CALL_STATE)) + .thenAnswer((_) => Future.value(2)); + final state = await telephony.callState; + verify(methodChannel.invokeMethod(GET_CALL_STATE)).called(1); + expect(state, CallState.OFFHOOK); + }); + + test("cellular data state", () async { + when(methodChannel.invokeMethod(GET_CELLULAR_DATA_STATE)) + .thenAnswer((_) => Future.value(0)); + final state = await telephony.cellularDataState; + verify(methodChannel.invokeMethod(GET_CELLULAR_DATA_STATE)).called(1); + expect(state, DataState.DISCONNECTED); + }); + + test("is sms capable", () async { + when(methodChannel.invokeMethod(IS_SMS_CAPABLE)) + .thenAnswer((_) => Future.value(true)); + final result = await telephony.isSmsCapable; + verify(methodChannel.invokeMethod(IS_SMS_CAPABLE)).called(1); + expect(result, true); + }); + }); + + group("should send", () { + test("sms", () async { + final String address = "0000000000"; + final String body = "Test message"; + when(methodChannel.invokeMethod(SEND_SMS, { + "address": address, + "message_body": body, + "listen_status": false + })).thenAnswer((realInvocation) => Future.value()); + telephony.sendSms(to: address, message: body); + verify(methodChannel.invokeMethod(SEND_SMS, { + "address": address, + "message_body": body, + "listen_status": false + })).called(1); + }); + + test("multipart message", () async { + final args = { + "address": "123456", + "message_body": "some long message", + "listen_status": false + }; + when(methodChannel.invokeMethod(SEND_MULTIPART_SMS, args)) + .thenAnswer((realInvocation) => Future.value()); + telephony.sendSms( + to: "123456", message: "some long message", isMultipart: true); + + verifyNever(methodChannel.invokeMethod(SEND_SMS, args)); + + verify(methodChannel.invokeMethod(SEND_MULTIPART_SMS, args)).called(1); + }); + + test("sms by default app", () async { + final String address = "123456"; + final String body = "message"; + when(methodChannel.invokeMethod( + SEND_SMS_INTENT, {"address": address, "message_body": body})) + .thenAnswer((realInvocation) => Future.value()); + telephony.sendSmsByDefaultApp(to: address, message: body); + + verify(methodChannel.invokeMethod( + SEND_SMS_INTENT, {"address": address, "message_body": body})) + .called(1); + }); + }); + + group("smsMessage fromMap should", () { + test("correctly parse SmsType", () { + final columns = DEFAULT_SMS_COLUMNS.toList(); + columns.add(SmsColumn.TYPE); + final message = mockMessageWithSmsType; + final sms = SmsMessage.fromMap(message, columns); + + expect(sms.type, equals(SmsType.MESSAGE_TYPE_INBOX)); + }); + + test("correctly parse SmsType when tryParse returns null", () { + final columns = DEFAULT_SMS_COLUMNS.toList(); + columns.add(SmsColumn.TYPE); + final message = mockMessageWithInvalidSmsType; + final sms = SmsMessage.fromMap(message, columns); + + expect(sms.type, equals(null)); + }); + }); + + group("should query", () { + test("inbox", () async { + final args = { + "projection": ["_id", "address", "body", "date"], + }; + + when(methodChannel.invokeMethod>( + GET_ALL_INBOX_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final inbox = await telephony.getInboxSms(); + + verify(methodChannel.invokeMethod(GET_ALL_INBOX_SMS, args)).called(1); + + expect( + inbox[0].equals( + SmsMessage.fromMap(mockMessages[0], DEFAULT_SMS_COLUMNS)), + isTrue); + expect( + inbox[1].equals( + SmsMessage.fromMap(mockMessages[1], DEFAULT_SMS_COLUMNS)), + isTrue); + }); + + test("inbox with filters", () async { + final columns = [SmsColumn.ID, SmsColumn.ADDRESS]; + final SmsFilter filter = SmsFilter.where(SmsColumn.ID) + .equals("3") + .and(SmsColumn.ADDRESS) + .like("mess"); + final sortOrder = [OrderBy(SmsColumn.ID, sort: Sort.ASC)]; + + final args = { + "projection": ["_id", "address"], + "selection": " _id = ? AND address LIKE ?", + "selection_args": ["3", "mess"], + "sort_order": "_id ASC" + }; + + when(methodChannel.invokeMethod>( + GET_ALL_INBOX_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final inbox = await telephony.getInboxSms( + columns: columns, filter: filter, sortOrder: sortOrder); + verify(await methodChannel.invokeMethod(GET_ALL_INBOX_SMS, args)) + .called(1); + expect(inbox[0].equals(SmsMessage.fromMap(mockMessages[0], columns)), + isTrue); + expect(inbox[1].equals(SmsMessage.fromMap(mockMessages[1], columns)), + isTrue); + }); + + test("sent", () async { + final args = { + "projection": ["_id", "address", "body", "date"], + }; + + when(methodChannel.invokeMethod>( + GET_ALL_SENT_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final sent = await telephony.getSentSms(); + + verify(methodChannel.invokeMethod(GET_ALL_SENT_SMS, args)).called(1); + + expect( + sent[0].equals( + SmsMessage.fromMap(mockMessages[0], DEFAULT_SMS_COLUMNS)), + isTrue); + expect( + sent[1].equals( + SmsMessage.fromMap(mockMessages[1], DEFAULT_SMS_COLUMNS)), + isTrue); + }); + + test("sent with filters", () async { + final columns = [SmsColumn.ID, SmsColumn.ADDRESS]; + final SmsFilter filter = SmsFilter.where(SmsColumn.ID) + .equals("4") + .and(SmsColumn.DATE) + .greaterThan("12"); + final sortOrder = [OrderBy(SmsColumn.ID, sort: Sort.ASC)]; + + final args = { + "projection": ["_id", "address"], + "selection": " _id = ? AND date > ?", + "selection_args": ["4", "12"], + "sort_order": "_id ASC" + }; + + when(methodChannel.invokeMethod>( + GET_ALL_SENT_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final sent = await telephony.getSentSms( + columns: columns, filter: filter, sortOrder: sortOrder); + verify(await methodChannel.invokeMethod(GET_ALL_SENT_SMS, args)) + .called(1); + expect(sent[0].equals(SmsMessage.fromMap(mockMessages[0], columns)), + isTrue); + expect(sent[1].equals(SmsMessage.fromMap(mockMessages[1], columns)), + isTrue); + }); + + test("draft", () async { + final args = { + "projection": ["_id", "address", "body", "date"], + }; + + when(methodChannel.invokeMethod>( + GET_ALL_DRAFT_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final drafts = await telephony.getDraftSms(); + + verify(methodChannel.invokeMethod(GET_ALL_DRAFT_SMS, args)).called(1); + + expect( + drafts[0].equals( + SmsMessage.fromMap(mockMessages[0], DEFAULT_SMS_COLUMNS)), + isTrue); + expect( + drafts[1].equals( + SmsMessage.fromMap(mockMessages[1], DEFAULT_SMS_COLUMNS)), + isTrue); + }); + + test("draft with filters", () async { + final columns = [SmsColumn.ID, SmsColumn.ADDRESS]; + final SmsFilter filter = SmsFilter.where(SmsColumn.ID) + .equals("4") + .and(SmsColumn.DATE) + .greaterThan("12"); + final sortOrder = [OrderBy(SmsColumn.ID, sort: Sort.ASC)]; + + final args = { + "projection": ["_id", "address"], + "selection": " _id = ? AND date > ?", + "selection_args": ["4", "12"], + "sort_order": "_id ASC" + }; + + when(methodChannel.invokeMethod>( + GET_ALL_DRAFT_SMS, args)) + .thenAnswer((_) => Future.value(mockMessages)); + + final drafts = await telephony.getDraftSms( + columns: columns, filter: filter, sortOrder: sortOrder); + verify(await methodChannel.invokeMethod(GET_ALL_DRAFT_SMS, args)) + .called(1); + expect(drafts[0].equals(SmsMessage.fromMap(mockMessages[0], columns)), + isTrue); + expect(drafts[1].equals(SmsMessage.fromMap(mockMessages[1], columns)), + isTrue); + }); + + test("conversations", () async { + final args = { + "projection": ["snippet", "thread_id", "msg_count"] + }; + + when(methodChannel.invokeMethod>( + GET_ALL_CONVERSATIONS, args)) + .thenAnswer((realInvocation) => Future.value(mockConversations)); + + final conversations = await telephony.getConversations(); + + verify(methodChannel.invokeMethod(GET_ALL_CONVERSATIONS, args)) + .called(1); + expect( + conversations[0] + .equals(SmsConversation.fromMap(mockConversations[0])), + isTrue); + expect( + conversations[1] + .equals(SmsConversation.fromMap(mockConversations[1])), + isTrue); + }); + + test("conversations with filter", () async { + final ConversationFilter filter = + ConversationFilter.where(ConversationColumn.MSG_COUNT) + .equals("4") + .and(ConversationColumn.THREAD_ID) + .greaterThan("12"); + final sortOrder = [ + OrderBy(ConversationColumn.THREAD_ID, sort: Sort.ASC) + ]; + + final args = { + "projection": ["snippet", "thread_id", "msg_count"], + "selection": " msg_count = ? AND thread_id > ?", + "selection_args": ["4", "12"], + "sort_order": "thread_id ASC" + }; + + when(methodChannel.invokeMethod>( + GET_ALL_CONVERSATIONS, args)) + .thenAnswer((realInvocation) => Future.value(mockConversations)); + + final conversations = await telephony.getConversations( + filter: filter, sortOrder: sortOrder); + + verify(await methodChannel.invokeMethod(GET_ALL_CONVERSATIONS, args)) + .called(1); + expect( + conversations[0] + .equals(SmsConversation.fromMap(mockConversations[0])), + isTrue); + expect( + conversations[1] + .equals(SmsConversation.fromMap(mockConversations[1])), + isTrue); + }); + }); + + group("should generate", () { + test("sms filter statement", () async { + final SmsFilter statement = SmsFilter.where(SmsColumn.ADDRESS) + .greaterThan("1") + .and(SmsColumn.ID) + .greaterThanOrEqualTo("2") + .or(SmsColumn.DATE) + .between("3", "4") + .or(SmsColumn.TYPE) + .not + .like("5"); + + expect( + statement.selection, + equals( + " address > ? AND _id >= ? OR date BETWEEN ? OR NOT type LIKE ?")); + expect( + ListEquality() + .equals(statement.selectionArgs, ["1", "2", "3 AND 4", "5"]), + isTrue); + }); + + test("conversation filter statement", () async { + final ConversationFilter statement = + ConversationFilter.where(ConversationColumn.THREAD_ID) + .lessThanOrEqualTo("1") + .or(ConversationColumn.MSG_COUNT) + .notEqualTo("6") + .and(ConversationColumn.SNIPPET) + .not + .notEqualTo("7"); + + expect(statement.selection, + " thread_id <= ? OR msg_count != ? AND NOT snippet != ?"); + expect(ListEquality().equals(statement.selectionArgs, ["1", "6", "7"]), + isTrue); + }); + }); + + group("should initiate call", () { + test("via default phone app", () async { + final args = {"phoneNumber": "123456789"}; + when(methodChannel.invokeMethod(OPEN_DIALER, args)) + .thenAnswer((realInvocation) async {}); + + await telephony.openDialer("123456789"); + + verify(methodChannel.invokeMethod(OPEN_DIALER, args)).called(1); + }); + + test('directly', () async { + final args = {"phoneNumber": "123456789"}; + when(methodChannel.invokeMethod(DIAL_PHONE_NUMBER, args)) + .thenAnswer((realInvocation) async {}); + + await telephony.dialPhoneNumber("123456789"); + + verify(methodChannel.invokeMethod(DIAL_PHONE_NUMBER, args)).called(1); + }); + }); + }); +} diff --git a/test/telephony_test.mocks.dart b/test/telephony_test.mocks.dart new file mode 100644 index 0000000..431e423 --- /dev/null +++ b/test/telephony_test.mocks.dart @@ -0,0 +1,65 @@ +// Mocks generated by Mockito 5.2.0 from annotations +// in telephony/test/telephony_test.dart. +// Do not manually edit this file. + +import 'dart:async' as _i5; + +import 'package:flutter/src/services/binary_messenger.dart' as _i3; +import 'package:flutter/src/services/message_codec.dart' as _i2; +import 'package:flutter/src/services/platform_channel.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types + +class _FakeMethodCodec_0 extends _i1.Fake implements _i2.MethodCodec {} + +class _FakeBinaryMessenger_1 extends _i1.Fake implements _i3.BinaryMessenger {} + +/// A class which mocks [MethodChannel]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { + MockMethodChannel() { + _i1.throwOnMissingStub(this); + } + + @override + String get name => + (super.noSuchMethod(Invocation.getter(#name), returnValue: '') as String); + @override + _i2.MethodCodec get codec => (super.noSuchMethod(Invocation.getter(#codec), + returnValue: _FakeMethodCodec_0()) as _i2.MethodCodec); + @override + _i3.BinaryMessenger get binaryMessenger => + (super.noSuchMethod(Invocation.getter(#binaryMessenger), + returnValue: _FakeBinaryMessenger_1()) as _i3.BinaryMessenger); + @override + _i5.Future invokeMethod(String? method, [dynamic arguments]) => + (super.noSuchMethod(Invocation.method(#invokeMethod, [method, arguments]), + returnValue: Future.value()) as _i5.Future); + @override + _i5.Future?> invokeListMethod(String? method, + [dynamic arguments]) => + (super.noSuchMethod( + Invocation.method(#invokeListMethod, [method, arguments]), + returnValue: Future?>.value()) as _i5.Future?>); + @override + _i5.Future?> invokeMapMethod(String? method, + [dynamic arguments]) => + (super.noSuchMethod( + Invocation.method(#invokeMapMethod, [method, arguments]), + returnValue: Future?>.value()) as _i5.Future?>); + @override + void setMethodCallHandler( + _i5.Future Function(_i2.MethodCall)? handler) => + super.noSuchMethod(Invocation.method(#setMethodCallHandler, [handler]), + returnValueForMissingStub: null); +}