From a7f9c11a4e2f5fb1dac1058c0c96435d31f29a29 Mon Sep 17 00:00:00 2001 From: wylyl22 <2373073266@qq.com> Date: Fri, 19 Aug 2022 10:16:45 +0800 Subject: [PATCH] first commit --- CHANGELOG.md | 66 ++ LICENSE | 21 + README.md | 326 ++++++++ android/build.gradle | 48 ++ android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 6 + android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 5 + .../telephony/PermissionsController.kt | 59 ++ .../shounakmulay/telephony/TelephonyPlugin.kt | 73 ++ .../telephony/sms/ContextHolder.kt | 8 + .../telephony/sms/IncomingSmsHandler.kt | 269 +++++++ .../telephony/sms/SmsController.kt | 266 +++++++ .../telephony/sms/SmsMethodCallHandler.kt | 401 ++++++++++ .../shounakmulay/telephony/utils/Constants.kt | 84 +++ .../shounakmulay/telephony/utils/SmsEnums.kt | 94 +++ coverage/lcov.info | 328 ++++++++ example/README.md | 17 + example/android/app/build.gradle | 63 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 60 ++ .../telephony_example/MainActivity.kt | 14 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle | 31 + example/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + example/android/settings.gradle | 15 + example/lib/main.dart | 77 ++ example/pubspec.yaml | 75 ++ example/test/widget_test.dart | 26 + ios/Classes/SwiftTelephonyPlugin.swift | 14 + ios/Classes/TelephonyPlugin.h | 4 + ios/Classes/TelephonyPlugin.m | 15 + ios/telephony.podspec | 23 + lib/constants.dart | 255 +++++++ lib/filter.dart | 200 +++++ lib/telephony.dart | 702 ++++++++++++++++++ pubspec.yaml | 66 ++ test/listener_test.dart | 66 ++ test/mocks/messages.dart | 50 ++ test/telephony_test.dart | 522 +++++++++++++ test/telephony_test.mocks.dart | 65 ++ 49 files changed, 4472 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/PermissionsController.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/TelephonyPlugin.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/sms/ContextHolder.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/sms/IncomingSmsHandler.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsController.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/sms/SmsMethodCallHandler.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/utils/Constants.kt create mode 100644 android/src/main/kotlin/com/shounakmulay/telephony/utils/SmsEnums.kt create mode 100644 coverage/lcov.info create mode 100644 example/README.md create mode 100644 example/android/app/build.gradle create mode 100644 example/android/app/src/debug/AndroidManifest.xml create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/kotlin/com/shounakmulay/telephony_example/MainActivity.kt create mode 100644 example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/app/src/profile/AndroidManifest.xml create mode 100644 example/android/build.gradle create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle create mode 100644 example/lib/main.dart create mode 100644 example/pubspec.yaml create mode 100644 example/test/widget_test.dart create mode 100644 ios/Classes/SwiftTelephonyPlugin.swift create mode 100644 ios/Classes/TelephonyPlugin.h create mode 100644 ios/Classes/TelephonyPlugin.m create mode 100644 ios/telephony.podspec create mode 100644 lib/constants.dart create mode 100644 lib/filter.dart create mode 100644 lib/telephony.dart create mode 100644 pubspec.yaml create mode 100644 test/listener_test.dart create mode 100644 test/mocks/messages.dart create mode 100644 test/telephony_test.dart create mode 100644 test/telephony_test.mocks.dart 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 +pQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`Y V6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_M sH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8 =l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$% 4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8h t^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z %$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z ~7YxD~Rf< (a@_y` literal 0 HcmV?d00001 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