From 541fbeaaf370b5188c50acaf41e9587a2c1c106a Mon Sep 17 00:00:00 2001 From: ksenia312 Date: Wed, 31 Jan 2024 14:16:22 +0100 Subject: [PATCH] first commit --- CHANGELOG.md | 3 + LICENSE | 21 + README.md | 7 + analysis_options.yaml | 4 + android/.gitignore | 9 + android/build.gradle | 70 ++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 240 +++++ android/gradlew.bat | 91 ++ android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 16 + .../com/xenikii/nearby_service/Extensions.kt | 29 + .../com/xenikii/nearby_service/Logger.kt | 38 + .../NearbyServiceBroadcastReceiver.kt | 112 +++ .../nearby_service/NearbyServiceManager.kt | 292 ++++++ .../NearbyServicePermissionsHandler.kt | 151 +++ .../nearby_service/NearbyServicePlugin.kt | 185 ++++ example/.gitignore | 46 + example/README.md | 16 + example/analysis_options.yaml | 29 + example/android/.gitignore | 13 + example/android/app/build.gradle | 72 ++ .../android/app/src/debug/AndroidManifest.xml | 3 + .../android/app/src/main/AndroidManifest.xml | 45 + .../nearby_service_example/MainActivity.kt | 6 + .../res/drawable-v21/launch_background.xml | 12 + .../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-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle | 31 + example/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + example/android/settings.gradle | 11 + example/ios/.gitignore | 34 + example/ios/Flutter/AppFrameworkInfo.plist | 26 + example/ios/Flutter/Debug.xcconfig | 2 + example/ios/Flutter/Release.xcconfig | 2 + example/ios/Podfile | 44 + example/ios/Runner.xcodeproj/project.pbxproj | 730 ++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 77 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + example/ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + example/ios/Runner/Base.lproj/Main.storyboard | 29 + example/ios/Runner/Info.plist | 57 ++ example/ios/Runner/Runner-Bridging-Header.h | 1 + example/ios/RunnerTests/RunnerTests.swift | 26 + example/lib/components/action_button.dart | 45 + example/lib/components/app_snack_bar.dart | 40 + example/lib/main.dart | 931 ++++++++++++++++++ example/pubspec.yaml | 26 + example/test/widget_test.dart | 29 + ios/.gitignore | 38 + ios/Assets/.gitkeep | 0 .../ConnectedDeviceStreamHandler.swift | 35 + ios/Classes/Logger.swift | 15 + ios/Classes/MyDeviceDataGenerator.swift | 90 ++ ios/Classes/NearbyDevice.swift | 86 ++ ios/Classes/NearbyDevicesStore.swift | 58 ++ ios/Classes/NearbyManager.swift | 154 +++ ios/Classes/NearbyMessageConverter.swift | 59 ++ ios/Classes/NearbyPeersStreamHandler.swift | 32 + ios/Classes/NearbyServicePlugin.swift | 137 +++ ios/Classes/NearbySession.swift | 51 + ios/nearby_service.podspec | 23 + lib/nearby_service.dart | 113 +++ lib/nearby_service_method_channel.dart | 59 ++ lib/nearby_service_platform_interface.dart | 59 ++ lib/src/models/models.dart | 3 + lib/src/models/nearby_device.dart | 69 ++ lib/src/models/nearby_device_status.dart | 36 + lib/src/models/nearby_message.dart | 45 + lib/src/platforms/android/android.dart | 4 + .../models/nearby_connection_info.dart | 36 + .../android/models/nearby_device.dart | 63 ++ .../android/nearby_android_service.dart | 84 ++ .../nearby_service_android_interface.dart | 57 ++ ...nearby_service_android_method_channel.dart | 67 ++ .../socket_service/nearby_socket_service.dart | 178 ++++ .../android/socket_service/network.dart | 71 ++ .../android/socket_service/ping_manager.dart | 25 + lib/src/platforms/ios/ios.dart | 3 + .../platforms/ios/models/nearby_device.dart | 45 + lib/src/platforms/ios/nearby_ios_service.dart | 168 ++++ .../ios/nearby_service_ios_interface.dart | 69 ++ .../nearby_service_ios_method_channel.dart | 95 ++ lib/src/types/communication_channel_data.dart | 28 + lib/src/types/initialize_data.dart | 5 + .../types/nearby_service_stream_listener.dart | 19 + lib/src/types/types.dart | 3 + lib/src/utils/decoder.dart | 33 + lib/src/utils/exception.dart | 23 + lib/src/utils/log_level.dart | 6 + lib/src/utils/logger.dart | 81 ++ lib/src/utils/stream_mapper.dart | 31 + lib/src/utils/utils.dart | 2 + pubspec.yaml | 31 + test/nearby_service_method_channel_test.dart | 32 + test/nearby_service_test.dart | 73 ++ 132 files changed, 6463 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/build.gradle create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/Extensions.kt create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/Logger.kt create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceBroadcastReceiver.kt create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceManager.kt create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePermissionsHandler.kt create mode 100644 android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePlugin.kt create mode 100644 example/.gitignore create mode 100644 example/README.md create mode 100644 example/analysis_options.yaml create mode 100644 example/android/.gitignore 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/xenikii/nearby_service_example/MainActivity.kt create mode 100644 example/android/app/src/main/res/drawable-v21/launch_background.xml 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-night/styles.xml 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/ios/.gitignore create mode 100644 example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 example/ios/Flutter/Debug.xcconfig create mode 100644 example/ios/Flutter/Release.xcconfig create mode 100644 example/ios/Podfile create mode 100644 example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner/AppDelegate.swift create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 example/ios/Runner/Info.plist create mode 100644 example/ios/Runner/Runner-Bridging-Header.h create mode 100644 example/ios/RunnerTests/RunnerTests.swift create mode 100644 example/lib/components/action_button.dart create mode 100644 example/lib/components/app_snack_bar.dart 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/.gitignore create mode 100644 ios/Assets/.gitkeep create mode 100644 ios/Classes/ConnectedDeviceStreamHandler.swift create mode 100644 ios/Classes/Logger.swift create mode 100644 ios/Classes/MyDeviceDataGenerator.swift create mode 100644 ios/Classes/NearbyDevice.swift create mode 100644 ios/Classes/NearbyDevicesStore.swift create mode 100644 ios/Classes/NearbyManager.swift create mode 100644 ios/Classes/NearbyMessageConverter.swift create mode 100644 ios/Classes/NearbyPeersStreamHandler.swift create mode 100644 ios/Classes/NearbyServicePlugin.swift create mode 100644 ios/Classes/NearbySession.swift create mode 100644 ios/nearby_service.podspec create mode 100644 lib/nearby_service.dart create mode 100644 lib/nearby_service_method_channel.dart create mode 100644 lib/nearby_service_platform_interface.dart create mode 100644 lib/src/models/models.dart create mode 100644 lib/src/models/nearby_device.dart create mode 100644 lib/src/models/nearby_device_status.dart create mode 100644 lib/src/models/nearby_message.dart create mode 100644 lib/src/platforms/android/android.dart create mode 100644 lib/src/platforms/android/models/nearby_connection_info.dart create mode 100644 lib/src/platforms/android/models/nearby_device.dart create mode 100644 lib/src/platforms/android/nearby_android_service.dart create mode 100644 lib/src/platforms/android/nearby_service_android_interface.dart create mode 100644 lib/src/platforms/android/nearby_service_android_method_channel.dart create mode 100644 lib/src/platforms/android/socket_service/nearby_socket_service.dart create mode 100644 lib/src/platforms/android/socket_service/network.dart create mode 100644 lib/src/platforms/android/socket_service/ping_manager.dart create mode 100644 lib/src/platforms/ios/ios.dart create mode 100644 lib/src/platforms/ios/models/nearby_device.dart create mode 100644 lib/src/platforms/ios/nearby_ios_service.dart create mode 100644 lib/src/platforms/ios/nearby_service_ios_interface.dart create mode 100644 lib/src/platforms/ios/nearby_service_ios_method_channel.dart create mode 100644 lib/src/types/communication_channel_data.dart create mode 100644 lib/src/types/initialize_data.dart create mode 100644 lib/src/types/nearby_service_stream_listener.dart create mode 100644 lib/src/types/types.dart create mode 100644 lib/src/utils/decoder.dart create mode 100644 lib/src/utils/exception.dart create mode 100644 lib/src/utils/log_level.dart create mode 100644 lib/src/utils/logger.dart create mode 100644 lib/src/utils/stream_mapper.dart create mode 100644 lib/src/utils/utils.dart create mode 100644 pubspec.yaml create mode 100644 test/nearby_service_method_channel_test.dart create mode 100644 test/nearby_service_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41cc7d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8bcb72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +Copyright 2024 Nikitina Kseniia (xenikii.one). + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following + disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS β€œAS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f4eaf4 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Nearby Service + + +

Connecting phones in a P2P network

+ +[![Xenikii Website](https://img.shields.io/badge/-xenikii.one-bd2727?style=flat&logoColor=white)](https://xenikii.one) +[![LICENSE BSD](https://img.shields.io/badge/License-BSD-4577d9)](https://github.com/ksenia312/nearby_service/blob/main/LICENSE) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..574e34d --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,70 @@ +group 'com.xenikii.nearby_service' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdk 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } + + defaultConfig { + minSdkVersion 24 + } + + dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.mockito:mockito-core:5.0.0' + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen { false } + showStandardStreams = true + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.1' +} + diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..2efcef5 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'nearby_service' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f327df6 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/Extensions.kt b/android/src/main/kotlin/com/xenikii/nearby_service/Extensions.kt new file mode 100644 index 0000000..1a31b1c --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/Extensions.kt @@ -0,0 +1,29 @@ +package com.xenikii.nearby_service + +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pInfo +import org.json.JSONObject + +fun WifiP2pDevice.toJsonString(): String { + val jsonObject = JSONObject() + jsonObject.put("deviceName", deviceName) + jsonObject.put("deviceAddress", deviceAddress) + jsonObject.put("isGroupOwner", isGroupOwner) + jsonObject.put("isServiceDiscoveryCapable", isServiceDiscoveryCapable) + jsonObject.put("primaryDeviceType", primaryDeviceType) + jsonObject.put("secondaryDeviceType", secondaryDeviceType) + jsonObject.put("wpsDisplaySupported", wpsDisplaySupported()) + jsonObject.put("wpsPbcSupported", wpsPbcSupported()) + jsonObject.put("wpsKeypadSupported", wpsKeypadSupported()) + jsonObject.put("status", status) + return jsonObject.toString() + +} + +fun WifiP2pInfo.toJsonString(): String { + val jsonObject = JSONObject() + jsonObject.put("groupFormed", groupFormed) + jsonObject.put("groupOwnerAddress", groupOwnerAddress) + jsonObject.put("isGroupOwner", isGroupOwner) + return jsonObject.toString() +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/Logger.kt b/android/src/main/kotlin/com/xenikii/nearby_service/Logger.kt new file mode 100644 index 0000000..d775b1d --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/Logger.kt @@ -0,0 +1,38 @@ +package com.xenikii.nearby_service + +import android.util.Log + +const val TAG = "NearbyService" + +/** + * Class for systemizing log levels. + */ +enum class LogLevel(val value: Int) { + DEBUG(1), + INFO(2), + ERROR(3), + DISABLED(4), +} + +class Logger { + companion object { + var level = LogLevel.DEBUG + fun d(message: String) { + if (level.value <= LogLevel.DEBUG.value) { + Log.d(TAG, message) + } + } + + fun i(message: String) { + if (level.value <= LogLevel.INFO.value) { + Log.i(TAG, message) + } + } + + fun e(message: String) { + if (level.value <= LogLevel.ERROR.value) { + Log.e(TAG, message) + } + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceBroadcastReceiver.kt b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceBroadcastReceiver.kt new file mode 100644 index 0000000..b45903c --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceBroadcastReceiver.kt @@ -0,0 +1,112 @@ +package com.xenikii.nearby_service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pDeviceList +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import android.net.wifi.p2p.WifiP2pManager.Channel +import android.os.Build + +/** + * Receiver of [WifiP2pManager] changes. + */ +class NearbyServiceBroadcastReceiver( + private val wifiManager: WifiP2pManager, + private val wifiChannel: Channel, + private val permissionsHandler: NearbyServicePermissionsHandler, +) : BroadcastReceiver() { + var peers: MutableList = mutableListOf() + var connectedDevice: WifiP2pDevice? = null + var currentDevice: WifiP2pDevice? = null + var wifiInfo: WifiP2pInfo? = null + + override fun onReceive(context: Context, intent: Intent) { + Logger.i("Received action ${intent.action?.replace("android.net.wifi.p2p.", "")}") + + when (intent.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + logState(intent) + writeConnectionInfo() + } + + WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> { + writeDevices() + } + + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + writeConnectionInfo() + } + + WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION -> { + writeConnectionInfo() + } + + WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> { + writeCurrentDevice(intent) + } + } + } + + private fun logState(intent: Intent) { + when (intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)) { + WifiP2pManager.WIFI_P2P_STATE_ENABLED -> { + Logger.d("P2P state enabled") + } + + else -> { + Logger.d("P2P state disabled") + } + } + } + + + private fun writeCurrentDevice(intent: Intent) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + @Suppress("DEPRECATION") + currentDevice = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE) + } + } + + private fun writeDevices() { + try { + wifiManager.requestPeers( + wifiChannel + ) { newPeers: WifiP2pDeviceList -> + val list: MutableList = mutableListOf() + + if (newPeers.deviceList.isEmpty() && connectedDevice != null) { + connectedDevice = null + } + for (device: WifiP2pDevice in newPeers.deviceList) { + list.add(device.toJsonString()) + if (device.status == WifiP2pDevice.CONNECTED) { + connectedDevice = device + } else if (device.deviceAddress == connectedDevice?.deviceAddress && + device.status != WifiP2pDevice.CONNECTED + ) { + connectedDevice = null + } + } + peers = list + } + } catch (e: SecurityException) { + if (!permissionsHandler.checkPermissions()) { + Logger.e("No permission to call 'writeDevices'") + permissionsHandler.requestPermissions() + } + } + } + + + private fun writeConnectionInfo() { + wifiManager.requestConnectionInfo(wifiChannel) { info: WifiP2pInfo -> + if (!info.groupFormed && wifiInfo?.groupFormed == true) { + writeDevices() + } + wifiInfo = info + } + } +} diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceManager.kt b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceManager.kt new file mode 100644 index 0000000..e8aa797 --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServiceManager.kt @@ -0,0 +1,292 @@ +package com.xenikii.nearby_service + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.WifiManager +import android.net.wifi.WpsInfo +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel.Result +import kotlinx.coroutines.future.await + +/** + * General Manager of Wi-fi Direct local network operations. + */ +class NearbyServiceManager(private var context: Context) { + private lateinit var wifiManager: WifiP2pManager + private lateinit var wifiChannel: WifiP2pManager.Channel + private lateinit var receiver: NearbyServiceBroadcastReceiver + + private val intentFilter = IntentFilter() + private var permissionsHandler = NearbyServicePermissionsHandler(context) + private var activityPluginBinding: ActivityPluginBinding? = null + + + /** + * Sets [binding] to [activityPluginBinding] and [permissionsHandler]. + * Adds permissions result listener to [binding]. + */ + fun setBinding(binding: ActivityPluginBinding) { + activityPluginBinding = binding + activityPluginBinding?.addRequestPermissionsResultListener(permissionsHandler) + permissionsHandler.activity = binding.activity + } + + /** + * Removes permissions result listener to [activityPluginBinding]. + * Sets [activityPluginBinding] to null. + */ + fun removeBinding() { + activityPluginBinding?.removeRequestPermissionsResultListener(permissionsHandler) + activityPluginBinding = null + } + + /** + * Initializes everything for [WifiManager] to work. + */ + fun initialize(result: Result, logLevel: String) { + Logger.level = LogLevel.valueOf(logLevel.uppercase()) + + addWifiActions() + initWifiManager() + initReceiver() + result.success(true) + } + + /** + * Requesting permissions with [permissionsHandler]. + */ + suspend fun requestPermissions(): Boolean { + return permissionsHandler.requestPermissionsAsync().await() + } + + /** + * Checking if Wi-fi is enabled now. + */ + fun checkWifiService(result: Result) { + result.success( + (context.getSystemService(Context.WIFI_SERVICE) as WifiManager).isWifiEnabled + ) + } + + /** + * Returns info about a current device in format WifiP2pDevice.toJsonString(). + * + * Note! + * The field **deviceAddress** will always be 02:00:00:00:00:00 for privacy issues. + * + * Note! + * If the SDK version is less than 29 (Q), tries to return a current device from [receiver]. + * It also may be null. + */ + fun getCurrentDevice(result: Result) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + wifiManager.requestDeviceInfo(wifiChannel) { device -> + result.success(device?.toJsonString()) + } + } else { + result.success(receiver.currentDevice?.toJsonString()) + } + } catch (e: SecurityException) { + if (!permissionsHandler.checkPermissions()) { + Logger.e("No permission to call 'discover'") + permissionsHandler.requestPermissions() + } + } + } + + /** + * Opens the phone settings under Wi-fi. + */ + fun openWifiSettings(result: Result) { + activityPluginBinding?.activity?.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) + result.success(true) + } + + /** + * Start discovery for peers in Wi-fi Direct scope. + * + * Note! + * All permissions from [NearbyServicePermissionsHandler] are required. + */ + fun discover(result: Result) { + try { + wifiManager.discoverPeers( + wifiChannel, getActionListener(result) + ) + } catch (e: SecurityException) { + if (!permissionsHandler.checkPermissions()) { + Logger.e("No permission to call 'discover'") + permissionsHandler.requestPermissions() + } + } + } + + /** + * Stop discovery for peers in Wi-fi Direct scope. + */ + fun stopDiscovery(result: Result) { + wifiManager.stopPeerDiscovery( + wifiChannel, getActionListener(result) + ) + } + + /** + * Returns peers from [NearbyServiceBroadcastReceiver]. + */ + fun getPeers(result: Result) { + result.success(receiver.peers) + } + + /** + * Returns connection info from [NearbyServiceBroadcastReceiver] in json string. + */ + fun getConnectionInfo(result: Result) { + val info = receiver.wifiInfo?.toJsonString() + result.success(info) + } + + /** + * Connects to provided [deviceAddress] in Wi-fi Direct scope. + */ + fun connect(result: Result, deviceAddress: String) { + val config = WifiP2pConfig() + if (receiver.connectedDevice?.deviceAddress == deviceAddress) { + Logger.i("Already connected to the device $deviceAddress") + result.success(true) + return + } + val actionListener = getActionListener( + result, + "Connected to device $deviceAddress", + "Connecting to device $deviceAddress failed" + ) + config.deviceAddress = deviceAddress + config.wps.setup = WpsInfo.PBC + try { + wifiChannel.also { wifiChannel: WifiP2pManager.Channel -> + wifiManager.connect(wifiChannel, config, actionListener) + } + } catch (e: SecurityException) { + if (!permissionsHandler.checkPermissions()) { + Logger.e("No permission to call 'connect'") + permissionsHandler.requestPermissions() + } + } + } + + /** + * Disconnect from a previous device in Wi-fi Direct scope. + */ + fun disconnect(result: Result? = null) { + val actionListener = getActionListener( + result, "Disconnected from last device", "Failed to disconnect" + ) + wifiManager.removeGroup(wifiChannel, actionListener) + + } + + + private fun addWifiActions() { + intentFilter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION) + intentFilter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION) + intentFilter.addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION) + intentFilter.addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION) + intentFilter.addAction(WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION) + } + + private fun initWifiManager() { + wifiManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager + wifiChannel = wifiManager.initialize(context, Looper.getMainLooper(), null) + } + + private fun initReceiver() { + receiver = NearbyServiceBroadcastReceiver( + wifiManager, + wifiChannel, + permissionsHandler, + ) + context.registerReceiver(receiver, intentFilter) + } + + private fun getActionListener( + result: Result?, + successMessage: String? = null, + errorMessage: String? = null, + ): WifiP2pManager.ActionListener { + return object : WifiP2pManager.ActionListener { + override fun onSuccess() { + if (successMessage != null) { + Logger.i(successMessage) + } + result?.success(true) + } + + override fun onFailure(reasonCode: Int) { + if (errorMessage != null) { + Logger.e("ERROR: $errorMessage Reason code: $reasonCode") + } + result?.success(false) + } + } + } + + + var peersHandler = object : EventChannel.StreamHandler { + private var handler: Handler = Handler(Looper.getMainLooper()) + private var eventSink: EventChannel.EventSink? = null + + val postCallback = object : Runnable { + override fun run() { + handler.post { eventSink?.success("${receiver.peers}") } + handler.postDelayed(this, 1000) + } + } + + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + onCancel(null) + Logger.d("Start listening peers") + eventSink = sink + handler.postDelayed(postCallback, 1000) + } + + override fun onCancel(p0: Any?) { + Logger.d("Kill last process listening peers") + eventSink = null + handler.removeCallbacks(postCallback) + } + } + + var connectedDeviceInfoHandler = object : EventChannel.StreamHandler { + private var handler: Handler = Handler(Looper.getMainLooper()) + private var eventSink: EventChannel.EventSink? = null + + val postCallback = object : Runnable { + override fun run() { + handler.post { eventSink?.success(receiver.connectedDevice?.toJsonString()) } + handler.postDelayed(this, 1000) + } + } + + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + onCancel(null) + eventSink = sink + Logger.d("Listen connected device") + handler.postDelayed(postCallback, 1000) + } + + override fun onCancel(p0: Any?) { + Logger.d("Kill last process connected device") + eventSink = null + handler.removeCallbacks(postCallback) + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePermissionsHandler.kt b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePermissionsHandler.kt new file mode 100644 index 0000000..0bab09f --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePermissionsHandler.kt @@ -0,0 +1,151 @@ +package com.xenikii.nearby_service + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import io.flutter.plugin.common.PluginRegistry +import kotlinx.coroutines.future.await +import java.util.concurrent.CompletableFuture + +/** + * Class representing permission. + * [name] is official permission name from [Manifest]. + * [code] is the value with which the permission will be requested - requestCode. + */ + +class AppPermission(val name: String, val code: Int) { + val future = CompletableFuture() +} + +/** + * The class responsible for plugin permissions. + * It contains different ways of checking and requesting them. + * Before you use it, make sure you set the [activity] externally. + */ +class NearbyServicePermissionsHandler(private var context: Context) : + PluginRegistry.RequestPermissionsResultListener { + + var activity: Activity? = null + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val nearbyPermission = AppPermission(Manifest.permission.NEARBY_WIFI_DEVICES, 98) + private val locationPermission = AppPermission(Manifest.permission.ACCESS_FINE_LOCATION, 99) + + + /** + * Sync checking permissions. + */ + fun checkPermissions(): Boolean { + val locationGranted = checkLocationPermission() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkNearbyPermission() && locationGranted + } else { + return locationGranted + } + } + + /** + * Sync requesting permissions. + */ + fun requestPermissions() { + Logger.i("Requesting all required permissions") + requestLocationPermission() + requestNearbyPermission() + } + + /** + * Async requesting permissions. + */ + suspend fun requestPermissionsAsync(): CompletableFuture { + val res = checkPermissions() + if (res) return CompletableFuture.completedFuture(true) + + Logger.i("Requesting all required permissions") + return CompletableFuture.completedFuture( + requestLocationPermission().await() && requestNearbyPermission().await() + ) + } + + /** + * Requesting location permission [Manifest.permission.ACCESS_FINE_LOCATION] + */ + private fun requestLocationPermission(): CompletableFuture { + if (checkLocationPermission()) { + return CompletableFuture.completedFuture(true) + } + activity?.requestPermissions( + arrayOf(locationPermission.name), locationPermission.code + ) + + return locationPermission.future + } + + /** + * Requesting nearby devices permission [Manifest.permission.NEARBY_WIFI_DEVICES]. + * Calls if [Build.VERSION.SDK_INT] is equal or more than [Build.VERSION_CODES.TIRAMISU] + */ + private fun requestNearbyPermission(): CompletableFuture { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + + if (checkNearbyPermission()) { + return CompletableFuture.completedFuture(true) + } + + activity?.requestPermissions( + arrayOf(nearbyPermission.name), nearbyPermission.code + ) + return nearbyPermission.future + } else { + return CompletableFuture.completedFuture(true) + } + } + + /** + * Checking location permission [Manifest.permission.ACCESS_FINE_LOCATION] + */ + private fun checkLocationPermission(): Boolean { + return context.checkSelfPermission( + locationPermission.name + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Checking nearby devices permission [Manifest.permission.NEARBY_WIFI_DEVICES]. + * Available if [Build.VERSION.SDK_INT] is equal or more than [Build.VERSION_CODES.TIRAMISU] + */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun checkNearbyPermission(): Boolean { + return context.checkSelfPermission( + nearbyPermission.name + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Permission result handler. + * Completes the future of permission with the provided [requestCode] if it was granted. + */ + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ): Boolean { + if (grantResults.isNotEmpty()) { + if (requestCode == locationPermission.code) { + val isGranted = checkLocationPermission() + locationPermission.future.complete(isGranted) + Logger.i("Location permission activity result: isGranted=$isGranted") + return isGranted + + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && requestCode == nearbyPermission.code) { + val isGranted = checkNearbyPermission() + nearbyPermission.future.complete(isGranted) + Logger.i("Nearby devices permission activity result: isGranted=$isGranted") + return isGranted + } + } + + return false + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePlugin.kt b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePlugin.kt new file mode 100644 index 0000000..c577c89 --- /dev/null +++ b/android/src/main/kotlin/com/xenikii/nearby_service/NearbyServicePlugin.kt @@ -0,0 +1,185 @@ +package com.xenikii.nearby_service + +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.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + + +const val CHANNEL_NAME = "nearby_service" +const val PEERS_CHANNEL_NAME = "nearby_service_peers" +const val CONNECTED_DEVICE_CHANNEL_NAME = "nearby_service_connected_device" + +/** + * Plugin for creating connections in the Wi-fi Direct scope. + */ +class NearbyServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware { + private lateinit var binaryMessenger: BinaryMessenger + private lateinit var channel: MethodChannel + private lateinit var manager: NearbyServiceManager + private lateinit var peersChannel: EventChannel + private lateinit var connectedDeviceChannel: EventChannel + + + @OptIn(DelicateCoroutinesApi::class) + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + + "getPlatformModel" -> { + result.success(android.os.Build.MODEL) + } + + "initialize" -> { + try { + manager.initialize(result, call.argument("logLevel") ?: "DEBUG") + } catch (e: Exception) { + onError(result, e) + } + } + + "requestPermissions" -> { + GlobalScope.launch { + try { + result.success(manager.requestPermissions()) + } catch (e: Exception) { + onError(result, e) + } + } + } + + "getCurrentDevice" -> { + try { + manager.getCurrentDevice(result) + } catch (e: Exception) { + onError(result, e) + } + + } + + + "checkWifiService" -> { + try { + manager.checkWifiService(result) + } catch (e: Exception) { + onError(result, e) + } + } + + "openServicesSettings" -> { + try { + manager.openWifiSettings(result) + } catch (e: Exception) { + onError(result, e) + } + } + + + "discover" -> { + try { + manager.discover(result) + } catch (e: Exception) { + onError(result, e) + } + } + + + "stopDiscovery" -> { + try { + manager.stopDiscovery(result) + } catch (e: Exception) { + onError(result, e) + } + } + + "getPeers" -> { + try { + manager.getPeers(result) + } catch (e: Exception) { + onError(result, e) + } + } + + "getConnectionInfo" -> { + try { + manager.getConnectionInfo(result) + } catch (e: Exception) { + onError(result, e) + } + } + + "connect" -> { + try { + manager.connect(result, call.argument("deviceAddress") ?: "") + } catch (e: Exception) { + onError(result, e) + } + } + + "disconnect" -> { + try { + manager.disconnect(result) + } catch (e: Exception) { + onError(result, e) + } + } + + else -> { + result.notImplemented() + } + } + } + + private fun onError(result: Result, e: Exception) { + Logger.e(e.message.toString()) + result.success(false) + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + binaryMessenger = flutterPluginBinding.binaryMessenger + manager = NearbyServiceManager(flutterPluginBinding.applicationContext) + + channel = MethodChannel(binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + + peersChannel = EventChannel(binaryMessenger, PEERS_CHANNEL_NAME) + peersChannel.setStreamHandler(manager.peersHandler) + + connectedDeviceChannel = EventChannel(binaryMessenger, CONNECTED_DEVICE_CHANNEL_NAME) + connectedDeviceChannel.setStreamHandler(manager.connectedDeviceInfoHandler) + } + + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + peersChannel.setStreamHandler(null) + connectedDeviceChannel.setStreamHandler(null) + manager.disconnect() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + manager.setBinding(binding) + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + manager.setBinding(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + manager.removeBinding() + } + + override fun onDetachedFromActivity() { + manager.removeBinding() + } +} diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..f55be74 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +ios/Podfile.lock \ No newline at end of file diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..f9c3db7 --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# nearby_service_example + +Demonstrates how to use the nearby_service 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..d2391e7 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,72 @@ +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 { + namespace "com.xenikii.nearby_service_example" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.xenikii.nearby_service_example" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion 24 + targetSdkVersion flutter.targetSdkVersion + 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..bbd7ee7 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3814983 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/xenikii/nearby_service_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/xenikii/nearby_service_example/MainActivity.kt new file mode 100644 index 0000000..5cdd24f --- /dev/null +++ b/example/android/app/src/main/kotlin/com/xenikii/nearby_service_example/MainActivity.kt @@ -0,0 +1,6 @@ +package com.xenikii.nearby_service_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*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`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!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`|?8ht^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-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + 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..cb1ef88 --- /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..399f698 --- /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..ce647a4 --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.4.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=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..3c472b9 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,11 @@ +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/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9625e10 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..164df53 --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..86cc64e --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,730 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 105AA12BAD8DD4B2A812DBD2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 624D99DEA58F3E8BD42B371A /* Pods_Runner.framework */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8648A20CA5045AFB8BB3E363 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9E0D8D755156697FC0D0C80 /* Pods_RunnerTests.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 05CF70028C0A81E25BD55790 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 26D9CFAD8440F20A9E463633 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3D2190ED2980EDDA878B3F59 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 624D99DEA58F3E8BD42B371A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9620C2F9D759E6071BD1FD4C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B3749AC35EBBA44996820B50 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E8C7D709D778AD6A303C6754 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F9E0D8D755156697FC0D0C80 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0CF42BA1FAAFA371D083248F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8648A20CA5045AFB8BB3E363 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 105AA12BAD8DD4B2A812DBD2 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 16C54B649C90DF9832FFDAE8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 624D99DEA58F3E8BD42B371A /* Pods_Runner.framework */, + F9E0D8D755156697FC0D0C80 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + D83928EFDF10A002814333D0 /* Pods */, + 16C54B649C90DF9832FFDAE8 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + D83928EFDF10A002814333D0 /* Pods */ = { + isa = PBXGroup; + children = ( + 05CF70028C0A81E25BD55790 /* Pods-Runner.debug.xcconfig */, + 9620C2F9D759E6071BD1FD4C /* Pods-Runner.release.xcconfig */, + 26D9CFAD8440F20A9E463633 /* Pods-Runner.profile.xcconfig */, + E8C7D709D778AD6A303C6754 /* Pods-RunnerTests.debug.xcconfig */, + 3D2190ED2980EDDA878B3F59 /* Pods-RunnerTests.release.xcconfig */, + B3749AC35EBBA44996820B50 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 52DB770C136B76078924CF59 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 0CF42BA1FAAFA371D083248F /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 65E91E1B2FC1A3C9E222C4B4 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 7E005F5F8255249BA748FE33 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 52DB770C136B76078924CF59 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 65E91E1B2FC1A3C9E222C4B4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7E005F5F8255249BA748FE33 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E8C7D709D778AD6A303C6754 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3D2190ED2980EDDA878B3F59 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B3749AC35EBBA44996820B50 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.xenikii.nearbyServiceExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..93d90ac --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..fef06d4 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..4760cc1 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,57 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nearby Service + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nearby_service_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + NSBonjourServices + + _mp-connection._tcp + + UIRequiresPersistentWiFi + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..9917163 --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,26 @@ +import Flutter +import UIKit +import XCTest + +@testable import nearby_service + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = NearbyServicePlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/example/lib/components/action_button.dart b/example/lib/components/action_button.dart new file mode 100644 index 0000000..5077c4a --- /dev/null +++ b/example/lib/components/action_button.dart @@ -0,0 +1,45 @@ +part of '../main.dart'; + +enum _ActionButtonType { + idle(Color(0xFF00C853)), + warning(Color(0xFFD50000)); + + const _ActionButtonType(this.color); + + final Color color; +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.onTap, + required this.title, + this.type = _ActionButtonType.idle, + }); + + final VoidCallback onTap; + final String title; + final _ActionButtonType type; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.white, + maximumSize: const Size(150, 70), + minimumSize: const Size(70, 70), + elevation: 3, + surfaceTintColor: type.color.withOpacity(0.05), + ), + child: Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: type.color, + height: 1, + ), + ), + ); + } +} diff --git a/example/lib/components/app_snack_bar.dart b/example/lib/components/app_snack_bar.dart new file mode 100644 index 0000000..2a7bfe3 --- /dev/null +++ b/example/lib/components/app_snack_bar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class AppShackBar { + AppShackBar._(); + + static ScaffoldFeatureController? show( + BuildContext context, + String title, { + String? subtitle, + }) { + return ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: RichText( + text: TextSpan( + text: '$title \n', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + children: [ + if (subtitle != null) + TextSpan( + text: subtitle, + style: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + ), + ], + ), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.only( + left: 12, + right: 12, + bottom: 20, + ), + backgroundColor: Colors.pink.shade800, + duration: const Duration(seconds: 2), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..0cbdea6 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,931 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:nearby_service/nearby_service.dart'; +import 'package:provider/provider.dart'; + +import 'components/app_snack_bar.dart'; + +part 'components/action_button.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final service = AppService(); + await service.getPlatformInfo(); + runApp( + MyApp(service: service), + ); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key, required this.service}); + + final AppService service; + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: service, + child: MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink), + ), + home: Scaffold( + appBar: AppBar( + title: const Text('Nearby service example app'), + ), + body: Consumer(builder: (context, service, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Platform: ${service.platformVersion}\n' + 'Model: ${service.platformModel}', + ), + if (service.currentDevice != null) + Text( + 'Device Name: ${service.currentDevice!.displayName}\n' + '${Platform.isIOS ? 'Device ID: ${service.currentDevice!.id}' : ''}\n', + ), + if (Platform.isIOS) + Text( + 'You are ${service.isIOSBrowser ? 'going to find your friend' : 'waiting for another user to connect'}\n', + ), + ], + ), + ), + Flexible( + child: MediaQuery.removePadding( + context: context, + removeLeft: true, + child: Stepper( + controlsBuilder: (context, _) => const SizedBox.shrink(), + currentStep: service.state.step, + steps: [ + ...AppState.steps.map((e) { + return Step( + title: Text( + e.title, + style: const TextStyle(fontSize: 14), + ), + subtitle: + e.subtitle != null ? Text(e.subtitle!) : null, + content: e.content, + isActive: e == service.state, + ); + }), + ], + ), + ), + ), + ], + ); + }), + ), + ), + ); + } +} + +/// +/// DOMAIN LEVEL +/// + +enum AppState { + idle(title: "Let's start!"), + permissions(title: "Provide permissions"), + checkServices(title: "Check services"), + selectClientType( + title: 'Do you want to find your friend from this device?', + subtitle: + 'Click "Yes" if you will search, click "No" if you will wait for your friend to connect', + ), + readyToDiscover(title: "Ready to discover!"), + discoveringPeers(title: "Discovering devices..."), + streamingPeers(title: "Peers stream got!"), + loadingConnection(title: "Loading your connection"), + connected(title: "Connected!"), + communicationChannelCreated(title: "You can communicate!"); + + static final List androidSteps = [ + AppState.idle, + AppState.permissions, + AppState.checkServices, + AppState.readyToDiscover, + AppState.discoveringPeers, + AppState.streamingPeers, + AppState.loadingConnection, + AppState.connected, + AppState.communicationChannelCreated, + ]; + static final List iosSteps = [ + AppState.idle, + AppState.selectClientType, + AppState.readyToDiscover, + AppState.discoveringPeers, + AppState.streamingPeers, + AppState.loadingConnection, + AppState.connected, + AppState.communicationChannelCreated, + ]; + + static final List steps = [ + if (Platform.isAndroid) ...androidSteps, + if (Platform.isIOS) ...iosSteps, + ]; + + const AppState({required this.title, this.subtitle}); + + final String title; + final String? subtitle; + + Widget get content { + return switch (this) { + (AppState.idle) => const _IdleBody(), + (AppState.permissions) => const _PermissionsBody(), + (AppState.checkServices) => const _CheckServiceBody(), + (AppState.selectClientType) => const _SelectClientTypeBody(), + (AppState.readyToDiscover) => const _ReadyBody(), + (AppState.discoveringPeers) => const _DiscoveringBody(), + (AppState.streamingPeers) => const _StreamingState(), + (AppState.loadingConnection) => + const Center(child: CircularProgressIndicator.adaptive()), + (AppState.connected) => const _ConnectedBody(), + (AppState.communicationChannelCreated) => const _ConnectedSocketBody(), + }; + } + + int get step { + return steps.indexOf(this); + } +} + +class AppService extends ChangeNotifier { + final _nearbyService = NearbyService.getInstance(); + + AppState state = AppState.idle; + List? peers; + NearbyDevice? connectedDevice; + NearbyDeviceInfo? currentDevice; + + String platformVersion = 'Unknown'; + String platformModel = 'Unknown'; + + StreamSubscription? peersSubscription; + StreamSubscription? connectedDeviceSubscription; + + bool get isCommunicationChannelConnecting { + return _nearbyService.isCommunicationChannelConnecting.value; + } + + bool get isIOSBrowser { + return _nearbyService.ios?.isBrowser.value ?? false; + } + + bool get isAndroidGroupOwner { + return Platform.isAndroid && + (_nearbyService.android?.connectionInfo?.isGroupOwner ?? false); + } + + Future getPlatformInfo() async { + platformVersion = await _nearbyService.getPlatformVersion() ?? 'Unknown'; + platformModel = await _nearbyService.getPlatformModel() ?? 'Unknown'; + notifyListeners(); + } + + Future getSavedDeviceName() async { + return (await _nearbyService.ios?.getSavedDeviceName()) ?? platformModel; + } + + Future initialize(String? iosDeviceName) async { + try { + await _nearbyService.initialize( + data: NearbyInitializeData(iosDeviceName: iosDeviceName), + ); + currentDevice = (await _nearbyService.getCurrentDevice())?.info; + updateState( + Platform.isAndroid ? AppState.permissions : AppState.selectClientType, + ); + } catch (e, s) { + if (kDebugMode) { + print(e); + print(s); + } + } finally { + notifyListeners(); + } + } + + Future requestPermissions() async { + try { + final result = await _nearbyService.android?.requestPermissions(); + if (result ?? false) { + updateState(AppState.checkServices); + } + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + Future checkWifiService() async { + final result = await _nearbyService.android?.checkWifiService(); + if (result ?? false) { + updateState(AppState.readyToDiscover); + return true; + } + return false; + } + + Future openServicesSettings() async { + await _nearbyService.openServicesSettings(); + } + + void setIsBrowser({required bool value}) { + _nearbyService.ios?.setIsBrowser(value: value); + updateState(AppState.readyToDiscover); + } + + Future discover() async { + try { + final result = await _nearbyService.discover(); + if (result) { + updateState(AppState.discoveringPeers); + } + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + Future stopDiscovery() async { + try { + final result = await _nearbyService.stopDiscovery(); + if (result) { + updateState(AppState.readyToDiscover); + } + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + Future startListeningPeers() async { + try { + peersSubscription = _nearbyService.getPeersStream().listen( + (event) { + peers = event; + notifyListeners(); + }, + ); + updateState(AppState.streamingPeers); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + } + + Future stopListeningPeers() async { + peersSubscription?.cancel(); + peers = null; + updateState(AppState.discoveringPeers); + } + + Future connect(NearbyDevice device) async { + try { + await _nearbyService.connect(device); + } catch (e) { + if (kDebugMode) { + print(e); + } + } + notifyListeners(); + } + + Future startListeningConnectedDevice(NearbyDevice device) async { + updateState(AppState.loadingConnection); + try { + connectedDeviceSubscription = + _nearbyService.getConnectedDeviceStream(device).listen( + (event) async { + final wasConnected = connectedDevice?.status.isConnected ?? false; + final nowConnected = event?.status.isConnected ?? false; + if (wasConnected && !nowConnected) { + restart(); + return; + } + connectedDevice = event; + if (connectedDevice != null && + state != AppState.connected && + state != AppState.communicationChannelCreated) { + updateState(AppState.connected); + } + notifyListeners(); + }, + ); + } catch (e) { + updateState(AppState.streamingPeers, shouldNotify: false); + } + notifyListeners(); + } + + Future stopListeningConnectedDevice() async { + await connectedDeviceSubscription?.cancel(); + await _nearbyService.endCommunicationChannel(); + connectedDeviceSubscription = null; + connectedDevice = null; + + notifyListeners(); + } + + Future startCommunicationChannel({ + ValueChanged? listener, + }) async { + final eventListener = NearbyServiceStreamListener( + onCreated: (_) { + updateState(AppState.communicationChannelCreated); + }, + onData: (event) { + listener?.call(event); + }, + onError: (e, [StackTrace? s]) { + restart(); + }, + ); + + await _nearbyService.startCommunicationChannel( + NearbyCommunicationChannelData( + connectedDevice!.info.id, + eventListener: eventListener, + ), + ); + } + + void send(String message) { + if (connectedDevice == null) return; + _nearbyService.send( + OutgoingNearbyMessage( + value: message, + receiver: connectedDevice!.info, + ), + ); + } + + Future disconnect(NearbyDevice device) async { + try { + await _nearbyService.disconnect(device); + } catch (e) { + if (kDebugMode) { + print(e); + } + } finally { + await restart(); + } + notifyListeners(); + } + + Future restart() async { + await stopListeningConnectedDevice(); + await stopListeningPeers(); + await stopDiscovery(); + + await discover(); + } + + void updateState(AppState state, {bool shouldNotify = true}) { + this.state = state; + if (shouldNotify) { + notifyListeners(); + } + } +} + +/// +/// APP STATES +/// +/// + +class _IdleBody extends StatefulWidget { + const _IdleBody(); + + @override + State<_IdleBody> createState() => _IdleBodyState(); +} + +class _IdleBodyState extends State<_IdleBody> { + late final controller = TextEditingController(); + bool initialized = false; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + context.read().getSavedDeviceName().then((value) { + controller.text = value; + controller.selection = TextSelection.collapsed(offset: value.length); + setState(() { + initialized = true; + }); + }); + }); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!initialized) { + return const Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Getting saved name...', + style: TextStyle(fontSize: 12), + ), + SizedBox(height: 10), + CircularProgressIndicator.adaptive(), + ], + ), + ); + } + return Center( + child: Column( + children: [ + if (Platform.isIOS) + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: TextFormField( + decoration: const InputDecoration( + labelText: 'Device Name', + hintText: 'Enter the name of your device', + ), + controller: controller, + ), + ), + _ActionButton( + onTap: () { + context.read().initialize(controller.text); + }, + title: 'Tap to start', + ), + ], + ), + ); + } +} + +class _PermissionsBody extends StatelessWidget { + const _PermissionsBody(); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, service, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ActionButton( + onTap: service.requestPermissions, + title: 'Request permissions', + ), + ], + ); + }); + } +} + +class _CheckServiceBody extends StatefulWidget { + const _CheckServiceBody(); + + @override + State<_CheckServiceBody> createState() => _CheckServiceBodyState(); +} + +class _CheckServiceBodyState extends State<_CheckServiceBody> { + bool showEnableButton = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ActionButton( + onTap: () { + context.read().checkWifiService().then((value) { + if (!value) { + setState(() { + showEnableButton = true; + }); + AppShackBar.show(context, 'Please enable Wi-fi'); + } + }); + }, + title: 'Check Wi-fi service', + ), + if (showEnableButton) + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: _ActionButton( + onTap: context.read().openServicesSettings, + title: 'Open settings', + ), + ), + ], + ); + } +} + +class _SelectClientTypeBody extends StatelessWidget { + const _SelectClientTypeBody(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + title: 'Yes', + onTap: () { + context.read().setIsBrowser(value: true); + }, + ), + const SizedBox(width: 10), + _ActionButton( + title: 'No', + onTap: () { + context.read().setIsBrowser(value: false); + }, + ), + ], + ); + } +} + +class _ReadyBody extends StatelessWidget { + const _ReadyBody(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ActionButton( + onTap: context.read().discover, + title: 'Start discover peers', + ), + const SizedBox(height: 10), + if (Platform.isIOS) + _ActionButton( + onTap: () { + context.read().updateState(AppState.selectClientType); + }, + title: 'Reselect client type', + type: _ActionButtonType.warning, + ), + ], + ); + } +} + +class _DiscoveringBody extends StatelessWidget { + const _DiscoveringBody(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ActionButton( + type: _ActionButtonType.warning, + onTap: context.read().stopDiscovery, + title: 'Stop discovery', + ), + const SizedBox(height: 10), + _ActionButton( + onTap: context.read().startListeningPeers, + title: 'Now it is discovering. Tap to get peers!', + ) + ], + ); + } +} + +class _StreamingState extends StatelessWidget { + const _StreamingState(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ActionButton( + type: _ActionButtonType.warning, + onTap: context.read().stopListeningPeers, + title: 'Stop stream peers', + ), + const SizedBox(height: 10), + const _PeersBody(), + ], + ); + } +} + +class _PeersBody extends StatelessWidget { + const _PeersBody(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, service, _) { + return (service.peers != null && service.peers!.isNotEmpty) + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...service.peers!.map( + (e) { + return Container( + padding: const EdgeInsets.only(bottom: 8.0), + child: _DevicePreview(device: e), + ); + }, + ), + ], + ) + : Text( + Platform.isAndroid || service.isIOSBrowser + ? 'No one here (' + : "Wait until someone invites you!", + textAlign: TextAlign.center, + ); + }, + ); + } +} + +class _ConnectedBody extends StatelessWidget { + const _ConnectedBody(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, service, _) { + final device = service.connectedDevice; + return device != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!device.status.isConnected) + const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text('Connection lost'), + ), + ) + else if (!device.status.isConnected) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Connection lost'), + _ActionButton( + onTap: () { + service.connect(device); + }, + title: 'Reconnect', + ), + ], + ) + else + _DevicePreview(device: device, largeView: true), + const SizedBox(height: 10), + if (!(service.isCommunicationChannelConnecting)) + _ActionButton( + onTap: () => service.startCommunicationChannel( + listener: (event) => AppShackBar.show( + Scaffold.of(context).context, + event.value, + subtitle: 'From ${event.sender.displayName} ' + '(ID: ${event.sender.id})', + ), + ), + title: 'Start communicate', + ) + else + Text( + 'Connecting socket.. ' + '${service.isAndroidGroupOwner ? "Waiting a client for connect" : "Waiting a connection"}', + ) + ], + ), + ) + : const SizedBox(); + }, + ); + } +} + +class _ConnectedSocketBody extends StatefulWidget { + const _ConnectedSocketBody(); + + @override + State<_ConnectedSocketBody> createState() => _ConnectedSocketBodyState(); +} + +class _ConnectedSocketBodyState extends State<_ConnectedSocketBody> { + String message = ''; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, service, _) { + final device = service.connectedDevice; + if (device == null) { + return Center( + child: _ActionButton( + onTap: service.restart, + title: 'Restart', + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _DevicePreview(device: device, largeView: true), + const SizedBox(height: 10), + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: TextField( + onChanged: (value) => setState(() { + message = value; + }), + decoration: const InputDecoration( + enabledBorder: OutlineInputBorder(), + border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder(), + hintText: 'Enter your message', + ), + ), + ), + const SizedBox(width: 10), + _ActionButton( + title: 'Send', + onTap: () { + service.send(message); + }, + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _DevicePreview extends StatelessWidget { + const _DevicePreview({required this.device, this.largeView = false}); + + final NearbyDevice device; + final bool largeView; + + @override + Widget build(BuildContext context) { + final color = device.status.isConnected + ? Colors.greenAccent.shade700 + : Colors.blueGrey; + + final avatar = CircleAvatar( + backgroundColor: Colors.pink.shade800, + foregroundColor: Colors.white, + maxRadius: largeView ? 100 : 30, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + device.info.displayName + .split(' ') + .map((e) => e.substring(0, 1).toUpperCase()) + .join(''), + style: TextStyle(fontSize: largeView ? 32 : 16), + ), + ), + ); + final name = Text( + '${device.info.displayName} ' + '${device.byPlatform(onAndroid: (d) => d.isGroupOwner ? " - group owner" : "") ?? ''}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ); + final id = Text( + 'ID: ${device.info.id}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 10, + ), + ); + final status = Text( + device.byPlatform( + onAny: (d) => d.status.name, + onIOS: (d) => + context.select((v) => v.isIOSBrowser) + ? "Peer found | ${d.status.name}" + : "Pending invitation | ${d.status.name}", + ) ?? + '', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + ), + ); + + final disconnectButton = device.status.isConnected + ? TextButton( + onPressed: () => context.read().disconnect(device), + child: const Text('Disconnect'), + ) + : null; + if (largeView) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + avatar, + const SizedBox(height: 10), + name, + const SizedBox(height: 5), + id, + if (disconnectButton != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: disconnectButton, + ), + ], + ); + } else { + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + if (!device.status.isConnected) { + context.read().connect(device); + } else { + context.read().startListeningConnectedDevice(device); + } + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 3, + child: Row( + children: [ + avatar, + const SizedBox(width: 10), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [name, id, status], + ), + ), + ], + ), + ), + Flexible( + child: Text( + device.status.isConnected ? 'Tap to chat' : 'Tap to connect', + style: TextStyle( + color: Colors.greenAccent.shade700, fontSize: 12), + textAlign: TextAlign.center, + ), + ) + ], + ), + ), + ); + } + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..c05d17f --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,26 @@ +name: nearby_service_example +description: Demonstrates how to use the nearby_service plugin. +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: '>=3.0.6 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + nearby_service: + path: ../ + permission_handler: ^11.0.1 + provider: ^6.1.1 + + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + +flutter: + uses-material-design: true diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..650d9bd --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,29 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. 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:nearby_service_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp( + service: AppService(), + )); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Text && widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/ConnectedDeviceStreamHandler.swift b/ios/Classes/ConnectedDeviceStreamHandler.swift new file mode 100644 index 0000000..49749b3 --- /dev/null +++ b/ios/Classes/ConnectedDeviceStreamHandler.swift @@ -0,0 +1,35 @@ +import Flutter + +class ConnectedDeviceStreamHandler: NSObject, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private var timer : Timer? + + func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { + let deviceId = arguments as! String + self.eventSink = eventSink + startSendingUpdates(deviceId: deviceId) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + stopSendingUpdates() + eventSink = nil + return nil + } + + func startSendingUpdates(deviceId:String) { + self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + var result: String? + if let device = NearbyDevicesStore.instance.find(for: deviceId) { + result = device.toJsonString() + } + self.eventSink?(result) + } + } + + func stopSendingUpdates() { + self.timer?.invalidate() + self.timer = nil + } +} diff --git a/ios/Classes/Logger.swift b/ios/Classes/Logger.swift new file mode 100644 index 0000000..945342e --- /dev/null +++ b/ios/Classes/Logger.swift @@ -0,0 +1,15 @@ +// +// Logger.swift +// nearby_service +// +// Created by Kseniia Nikitina on 26/1/24. +// + +import Foundation + + +class Logger { + static func error(message: String) { + NSLog("NearbyServicePluginError -- %@", message) + } +} diff --git a/ios/Classes/MyDeviceDataGenerator.swift b/ios/Classes/MyDeviceDataGenerator.swift new file mode 100644 index 0000000..0346487 --- /dev/null +++ b/ios/Classes/MyDeviceDataGenerator.swift @@ -0,0 +1,90 @@ +import Foundation +import MultipeerConnectivity + +let SERVICE_TYPE = "mp-connection" +let PEER_ID = "PEER-ID" +let DEVICE_NAME = "DEVICE-NAME" + +class MyDeviceDataGenerator { + static func generate(name: String?) -> NearbyDevice { + return NearbyDevice( + peerID: ArchivedData.getPeerID(), + name: ArchivedData.getRequireName(from: name), + deviceType: UIDevice.current.model, + os: UIDevice.current.systemName, + osVersion: UIDevice.current.systemVersion + ) + } + +} + + +class ArchivedData { + static func getPeerID() -> MCPeerID { + if let archivedPeerID = getArchivedPeerID() { + return archivedPeerID + } else { + let peerID = MCPeerID( + displayName: UIDevice.current.name.replacingOccurrences(of: " ", with: "_") + + "_" + + String(Int.random(in: 1000..<50000)) + ) + savePeerID(for: peerID) + return peerID + } + } + + static func getRequireName(from name: String?) -> String { + let archivedName = ArchivedData.getArchivedName() + if let newName = name { + if (archivedName != newName) { + ArchivedData.saveName(for: newName) + } + return newName + } + return archivedName ?? UIDevice.current.name + } + + static func getArchivedPeerID() -> MCPeerID? { + guard let savedData = UserDefaults.standard.data(forKey: PEER_ID), + let unarchivedPeerID = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MCPeerID.self, from: savedData) + else { + return nil + } + return unarchivedPeerID + } + + static func savePeerID(for peerID: MCPeerID) { + + do { + UserDefaults.standard.set( + try NSKeyedArchiver.archivedData(withRootObject: peerID, requiringSecureCoding: false), + forKey: PEER_ID + ) + } catch let e { + Logger.error(message: e.localizedDescription) + } + } + + static func getArchivedName() -> String? { + guard let savedData = UserDefaults.standard.data(forKey: DEVICE_NAME), + let unarchivedName = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSData.self, from: savedData) + else { + return nil + } + return String(data: unarchivedName as Data, encoding: .utf8) + } + + static func saveName(for name: String) { + if let data = name.data(using: .utf8) { + do { + UserDefaults.standard.set( + try NSKeyedArchiver.archivedData(withRootObject: data, requiringSecureCoding: false), + forKey: DEVICE_NAME + ) + } catch let e { + Logger.error(message: e.localizedDescription) + } + } + } +} diff --git a/ios/Classes/NearbyDevice.swift b/ios/Classes/NearbyDevice.swift new file mode 100644 index 0000000..e8dc9f5 --- /dev/null +++ b/ios/Classes/NearbyDevice.swift @@ -0,0 +1,86 @@ + +import Foundation +import MultipeerConnectivity + +class NearbyDevice : NSObject { + var name: String + var peerID: MCPeerID + var deviceType: String? + var os: String? + var osVersion: String? + var session: NearbySession? + + + init( + peerID: MCPeerID, + name:String, + deviceType:String? = nil, + os: String? = nil, + osVersion: String? = nil + ) { + self.name=name + self.peerID = peerID + self.deviceType=deviceType + self.os=os + self.osVersion=osVersion + } + + func createSession(for peerID: MCPeerID) -> NearbySession { + self.session = NearbySession.create(peerID: peerID) + return session! + } + + func deleteSession() { + self.session = nil + } + +} + +extension NearbyDevice { + static func fromDictionary(for dictionary: [String: String]?, with peerID: MCPeerID) -> NearbyDevice { + let device = NearbyDevice( + peerID: peerID, + name:(dictionary?["displayName"] as String?) ?? peerID.displayName, + deviceType: dictionary?["deviceType"] as String?, + os: dictionary?["os"] as String?, + osVersion: dictionary?["osVersion"] as String? + ) + return device + + } + + func toDictionary() -> [String: String] { + var deviceDict: [String: String] = [ + "displayName": self.name, + "id": self.peerID.displayName, + ] + if let os = self.os { + deviceDict["os"] = os + } + if let deviceType = self.deviceType { + deviceDict["deviceType"] = deviceType + } + if let osVersion = self.osVersion { + deviceDict["osVersion"] = osVersion + } + if let session = self.session { + deviceDict["state"] = String(session.state.rawValue) + } else { + deviceDict["state"] = String(0) + } + return deviceDict + } + + func toJsonString() -> String? { + var result: String? + do { + let jsonData = try JSONSerialization.data(withJSONObject: toDictionary()) + if let jsonString = String(data: jsonData, encoding: .utf8) { + result = jsonString + } + } catch let error { + Logger.error(message: error.localizedDescription) + } + return result + } +} diff --git a/ios/Classes/NearbyDevicesStore.swift b/ios/Classes/NearbyDevicesStore.swift new file mode 100644 index 0000000..a89c379 --- /dev/null +++ b/ios/Classes/NearbyDevicesStore.swift @@ -0,0 +1,58 @@ +// +// NearbyDevicesStore.swift +// nearby_service +// +// Created by Kseniia Nikitina on 16/1/24. +// + +import Foundation +import MultipeerConnectivity + +class NearbyDevicesStore : NSObject { + static let instance = NearbyDevicesStore() + + private var devices : [NearbyDevice] = [] + + func getDevices() -> [NearbyDevice] { + return devices + } + + func find(for deviceId: String) -> NearbyDevice? { + return devices.first { device in + return device.peerID.displayName == deviceId + } + } + + func getDevicesToJsonString() -> String { + let devicesObject = devices.map { device in + return device.toDictionary() + } + do { + let jsonData = try JSONSerialization.data(withJSONObject: devicesObject) + if let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + } catch { + return "[]" + } + return "[]" + } + + + func add(for peerID: MCPeerID, discoveryInfo: [String: String]? = nil) -> NearbyDevice? { + devices = devices.filter{$0.peerID.displayName != peerID.displayName} + + let device = NearbyDevice.fromDictionary(for: discoveryInfo, with: peerID) + self.devices.append(device) + + return device + } + + func remove(for peerID: MCPeerID) { + self.devices = devices.filter{$0.peerID.displayName != peerID.displayName} + } + + func clear() { + self.devices = [] + } +} diff --git a/ios/Classes/NearbyManager.swift b/ios/Classes/NearbyManager.swift new file mode 100644 index 0000000..a59b8ee --- /dev/null +++ b/ios/Classes/NearbyManager.swift @@ -0,0 +1,154 @@ +import Foundation +import Flutter +import MultipeerConnectivity + +class NearbyManager: NSObject { + var device: NearbyDevice! + var advertiser: MCNearbyServiceAdvertiser! + var browser: MCNearbyServiceBrowser! + var invitationHandlers: [String: ((Bool, MCSession?) -> Void)] = [:] + + + func initialize(for deviceName: String? = nil, result: @escaping FlutterResult) { + self.device = MyDeviceDataGenerator.generate(name: deviceName) + + self.advertiser = MCNearbyServiceAdvertiser( + peer: self.device.peerID, + discoveryInfo: self.device.toDictionary(), + serviceType: SERVICE_TYPE + ) + self.advertiser.delegate = self + + self.browser = MCNearbyServiceBrowser(peer: self.device.peerID, serviceType: SERVICE_TYPE) + self.browser.delegate = self + + result(true) + } + + func getSavedDeviceName(result: @escaping FlutterResult) { + result(ArchivedData.getArchivedName()) + } + + func getCurrentDevice(result: @escaping FlutterResult) { + result(device.toJsonString()) + } + + func openServicesSettings(result: @escaping FlutterResult) { + if let url = URL(string:UIApplication.openSettingsURLString) { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + result(true) + } + + func startAdvertising(result: @escaping FlutterResult) { + self.advertiser.startAdvertisingPeer() + result(true) + } + + func startBrowsing(result: @escaping FlutterResult) { + self.browser.startBrowsingForPeers() + result(true) + } + + func stopAdvertising(result: @escaping FlutterResult) { + self.advertiser.stopAdvertisingPeer() + NearbyDevicesStore.instance.clear() + result(true) + } + + func stopBrowsing(result: @escaping FlutterResult) { + self.browser.stopBrowsingForPeers() + NearbyDevicesStore.instance.clear() + result(true) + } + + func getPeers(result: @escaping FlutterResult) { + result(NearbyDevicesStore.instance.getDevicesToJsonString()) + } + + func invite(for deviceId: String, result: @escaping FlutterResult) { + do { + let device = NearbyDevicesStore.instance.find(for: deviceId) + if let requireDevice = device { + let nearbySession = requireDevice.createSession(for: self.device.peerID) + self.browser.invitePeer( + requireDevice.peerID, + to: nearbySession.session, + withContext: try JSONSerialization.data(withJSONObject:["displayName": self.device.name]), + timeout: 0 + ) + result(true) + } + } catch let error { + Logger.error(message: error.localizedDescription) + result(false) + } + } + func acceptInvite(for deviceId: String, result: @escaping FlutterResult) { + let device = NearbyDevicesStore.instance.find(for: deviceId) + if let requireDevice = device { + let nearbySession = requireDevice.createSession(for: self.device.peerID) + self.invitationHandlers[deviceId]?(true, nearbySession.session) + result(true) + } + } + + func disconnect(for deviceId: String, result: @escaping FlutterResult) { + let device = NearbyDevicesStore.instance.find(for: deviceId) + device?.deleteSession() + result(true) + } + + func send(for message: String, with receiverId: String, result: @escaping FlutterResult) { + let device = NearbyDevicesStore.instance.find(for: receiverId) + + do { + if let requireDevice = device { + let data = [ + "name": self.device.name, + "message": message + ] + try requireDevice.session?.session?.send( + try JSONSerialization.data(withJSONObject: data), + toPeers: [requireDevice.peerID], + with: MCSessionSendDataMode.reliable + ) + } + } catch let error { + Logger.error(message: error.localizedDescription) + } + + result(true) + } + + +} + +extension NearbyManager: MCNearbyServiceAdvertiserDelegate { + func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void + ) { + var dict: Dictionary? + do { + dict = try JSONSerialization.jsonObject(with: context ?? Data()) as? Dictionary + } catch let error { + Logger.error(message: error.localizedDescription) + } + _ = NearbyDevicesStore.instance.add(for: peerID, discoveryInfo: dict) + self.invitationHandlers[peerID.displayName] = invitationHandler + + } +} + +extension NearbyManager: MCNearbyServiceBrowserDelegate { + func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) { + _ = NearbyDevicesStore.instance.add(for: peerID, discoveryInfo: info) + } + + func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { + let device = NearbyDevicesStore.instance.find(for: peerID.displayName) + device?.deleteSession() + NearbyDevicesStore.instance.remove(for: peerID) + } +} diff --git a/ios/Classes/NearbyMessageConverter.swift b/ios/Classes/NearbyMessageConverter.swift new file mode 100644 index 0000000..4f05f39 --- /dev/null +++ b/ios/Classes/NearbyMessageConverter.swift @@ -0,0 +1,59 @@ +// +// NearbyMessageConverter.swift +// nearby_service +// +// Created by Kseniia Nikitina on 29/1/24. +// + +import Foundation +import MultipeerConnectivity + +class NearbyMessageConverter { + static func convert(userInfo: [AnyHashable : Any]?) -> String? { + if let data = getMessageData(userInfo: userInfo), + let peerID = getMessagePeerID(userInfo: userInfo) { + return createMessage(data: data, peerID: peerID) + } + return nil + } + + static private func getMessageData(userInfo: [AnyHashable : Any]?) -> [String: String]? { + do { + if let data = userInfo?["data"] as? Data, + let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] { + return dictionary + } + } catch let error { + Logger.error(message: error.localizedDescription) + } + return nil + } + + static private func getMessagePeerID(userInfo: [AnyHashable:Any]?) -> MCPeerID? { + return userInfo?["from"] as? MCPeerID + + } + + static private func createMessage(data: [String: String], peerID: MCPeerID)-> String? { + do { + if let message = data["message"], + let name = data["name"] { + + var result: String? + let jsonObject = [ + "message": message, + "sender": ["id": peerID.displayName, "displayName": name], + ] as [String : Any] + + let jsonData = try JSONSerialization.data(withJSONObject: jsonObject) + if let jsonString = String(data: jsonData, encoding: .utf8) { + result = jsonString + } + return result + } + } catch let error { + Logger.error(message: error.localizedDescription) + } + return nil + } +} diff --git a/ios/Classes/NearbyPeersStreamHandler.swift b/ios/Classes/NearbyPeersStreamHandler.swift new file mode 100644 index 0000000..5738599 --- /dev/null +++ b/ios/Classes/NearbyPeersStreamHandler.swift @@ -0,0 +1,32 @@ +import Flutter + +class NearbyPeersStreamHandler: NSObject, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private var timer : Timer? + + func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = eventSink + startSendingUpdates() + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + stopSendingUpdates() + eventSink = nil + return nil + } + + func startSendingUpdates() { + self.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + let devicesList = NearbyDevicesStore.instance.getDevicesToJsonString() + self.eventSink?(devicesList) + } + } + + func stopSendingUpdates() { + self.timer?.invalidate() + self.timer = nil + } +} diff --git a/ios/Classes/NearbyServicePlugin.swift b/ios/Classes/NearbyServicePlugin.swift new file mode 100644 index 0000000..60829af --- /dev/null +++ b/ios/Classes/NearbyServicePlugin.swift @@ -0,0 +1,137 @@ +import Flutter +import MultipeerConnectivity +import UIKit + +public class NearbyServicePlugin: NSObject, FlutterPlugin { + + let manager: NearbyManager; + let channel: FlutterMethodChannel + + init(manager: NearbyManager, channel: FlutterMethodChannel) { + self.manager = manager + self.channel = channel + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "nearby_service", + binaryMessenger: registrar.messenger() + ) + let nearbyPeersChannel = FlutterEventChannel( + name: "nearby_service_peers", + binaryMessenger: registrar.messenger() + ) + let connectedDeviceChannel = FlutterEventChannel( + name: "nearby_service_connected_device", + binaryMessenger: registrar.messenger() + ) + let nearbyPeersStreamHandler = NearbyPeersStreamHandler() + nearbyPeersChannel.setStreamHandler(nearbyPeersStreamHandler) + + let connectedDeviceStreamHandler = ConnectedDeviceStreamHandler() + connectedDeviceChannel.setStreamHandler(connectedDeviceStreamHandler) + + let manager = NearbyManager() + let instance = NearbyServicePlugin(manager: manager, channel: channel) + + registrar.addMethodCallDelegate(instance, channel: channel) + + NotificationCenter.default.addObserver( + instance, + selector: #selector(messageReceived), + name: NearbySession.messageReceived, + object: nil + ) + + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + NearbyDevicesStore.instance.clear() + } + + @objc func messageReceived(notification: Notification) { + DispatchQueue.main.async { + let result = NearbyMessageConverter.convert(userInfo: notification.userInfo) + self.channel.invokeMethod("invoke_nearby_service_message_received", arguments: result) + } + } + + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + case "getPlatformModel": + result(UIDevice.current.name) + case "initialize": + if let deviceName: String = getArgument(for: "deviceName", call: call) { + manager.initialize(for: deviceName, result: result) + } else { + manager.initialize(for: nil, result: result) + } + case "getSavedDeviceName": + manager.getSavedDeviceName(result: result) + case "getCurrentDevice": + manager.getCurrentDevice(result: result) + case "openServicesSettings": + manager.openServicesSettings(result: result) + case "startAdvertising": + manager.startAdvertising(result: result) + case "startBrowsing": + manager.startBrowsing(result: result) + case "stopAdvertising": + manager.stopAdvertising(result: result) + case "stopBrowsing": + manager.stopBrowsing(result: result) + case "getPeers": + manager.getPeers(result: result) + case "invite": + if let deviceId: String = getArgument(for: "deviceId", call: call) { + manager.invite(for: deviceId, result: result) + } else { + result(false) + } + + case "acceptInvite": + if let deviceId : String = getArgument(for: "deviceId", call: call) { + manager.acceptInvite(for: deviceId, result: result) + } else { + result(false) + } + + case "disconnect": + if let deviceId: String = getArgument(for: "deviceId", call: call) { + manager.disconnect(for: deviceId, result: result) + } else { + result(false) + } + + case "send": + if let message: String = getArgument(for: "message", call: call) { + if let receiver : Dictionary = getArgument(for: "receiver", call: call), + let receiverId : String = receiver["id"] as? String + { + manager.send(for: message, with: receiverId, result: result) + } else { + result(false) + } + } else { + result(false) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + private func getArgument(for name: String, call: FlutterMethodCall) -> T? { + guard let data = call.arguments as? Dictionary else { + return nil + } + guard let argument: T = data[name] as? T else { + return nil + } + return argument + } +} diff --git a/ios/Classes/NearbySession.swift b/ios/Classes/NearbySession.swift new file mode 100644 index 0000000..10138ba --- /dev/null +++ b/ios/Classes/NearbySession.swift @@ -0,0 +1,51 @@ +// +// NearbySession.swift +// nearby_service +// +// Created by Kseniia Nikitina on 16/1/24. +// + +import Foundation +import MultipeerConnectivity + +class NearbySession: NSObject { + var session: MCSession! + var state: MCSessionState = MCSessionState.notConnected + + private init(peerID: MCPeerID) { + self.session = MCSession(peer: peerID) + } + + static let messageReceived = Notification.Name("NearbySessionReceivedMessage") + + static func create(peerID: MCPeerID) -> NearbySession { + let instance = NearbySession(peerID: peerID) + instance.session.delegate = instance + return instance + } +} + + +extension NearbySession: MCSessionDelegate { + func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) { + self.state = state + + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + NotificationCenter.default.post( + name: NearbySession.messageReceived, + object: nil, + userInfo: ["from": peerID, "data": data] + ) + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { + } + + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { + } + + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) { + } +} diff --git a/ios/nearby_service.podspec b/ios/nearby_service.podspec new file mode 100644 index 0000000..4fefd73 --- /dev/null +++ b/ios/nearby_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint nearby_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'nearby_service' + 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, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/lib/nearby_service.dart b/lib/nearby_service.dart new file mode 100644 index 0000000..6ad26a1 --- /dev/null +++ b/lib/nearby_service.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/nearby_service_platform_interface.dart'; +import 'package:nearby_service/src/utils/logger.dart'; + +export 'package:nearby_service/src/platforms/android/android.dart'; +export 'package:nearby_service/src/platforms/ios/ios.dart'; +export 'src/models/models.dart'; +export 'src/utils/utils.dart'; +export 'src/types/types.dart'; + +abstract class NearbyService { + static NearbyService getInstance({NearbyServiceLogLevel? logLevel}) { + if (logLevel != null) { + Logger.level = logLevel; + } + + if (Platform.isAndroid) { + Logger.debug('Created Nearby Android Service'); + return NearbyAndroidService(); + } else if (Platform.isIOS) { + Logger.debug('Created Nearby IOS Service'); + return NearbyIOSService(); + } else { + throw NearbyServiceException.unsupportedPlatform( + caller: 'getInstance()', + ); + } + } + + late final NearbyIOSService? ios = get( + onIOS: (e) => e, + ); + late final NearbyAndroidService? android = get( + onAndroid: (e) => e, + ); + + ValueListenable get isCommunicationChannelConnecting; + + Future getPlatformVersion() { + return NearbyServicePlatform.instance.getPlatformVersion(); + } + + Future getPlatformModel() { + return NearbyServicePlatform.instance.getPlatformModel(); + } + + Future getCurrentDevice() { + return NearbyServicePlatform.instance.getCurrentDevice(); + } + + Future openServicesSettings() { + return NearbyServicePlatform.instance.openServicesSettings(); + } + + Future> getPeers() { + return NearbyServicePlatform.instance.getPeers(); + } + + Stream> getPeersStream() { + return NearbyServicePlatform.instance.getPeersStream(); + } + + Stream getConnectedDeviceStream(NearbyDevice device) { + return NearbyServicePlatform.instance.getConnectedDeviceStream(device); + } + + Future initialize({ + NearbyInitializeData data = const NearbyInitializeData(), + }); + + Future discover(); + + Future stopDiscovery(); + + Future connect(NearbyDevice device); + + Future disconnect(NearbyDevice device); + + FutureOr startCommunicationChannel( + NearbyCommunicationChannelData data, + ); + + FutureOr endCommunicationChannel(); + + FutureOr send(OutgoingNearbyMessage message); +} + +extension NearbyServiceGetterExtension on NearbyService { + T? get({ + T Function(NearbyAndroidService)? onAndroid, + T Function(NearbyIOSService)? onIOS, + T Function(NearbyService)? onAny, + }) { + assert( + onAndroid != null || onIOS != null || onAny != null, + 'You should provide at least one of (onAndroid, onIOS, onAny)', + ); + if (this is NearbyAndroidService && onAndroid != null) { + return onAndroid(this as NearbyAndroidService); + } + if (this is NearbyIOSService && onIOS != null) { + return onIOS(this as NearbyIOSService); + } + if (onAny != null) { + return onAny(this); + } + return null; + } +} diff --git a/lib/nearby_service_method_channel.dart b/lib/nearby_service_method_channel.dart new file mode 100644 index 0000000..def4f6e --- /dev/null +++ b/lib/nearby_service_method_channel.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nearby_service/nearby_service.dart'; + +import 'nearby_service_platform_interface.dart'; + +/// An implementation of [NearbyServicePlatform] that uses method channels. +class MethodChannelNearbyService extends NearbyServicePlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('nearby_service'); + + @override + Future getPlatformVersion() { + return methodChannel.invokeMethod('getPlatformVersion'); + } + + @override + Future getPlatformModel() { + return methodChannel.invokeMethod('getPlatformModel'); + } + + @override + Future getCurrentDevice() async { + return NearbyDeviceMapper.instance.mapToDevice( + await methodChannel.invokeMethod('getCurrentDevice'), + ); + } + + @override + Future openServicesSettings() async { + await methodChannel.invokeMethod('openServicesSettings'); + } + + @override + Future> getPeers() async { + return NearbyDeviceMapper.instance.mapToDeviceList( + await methodChannel.invokeMethod('fetchPeers'), + ); + } + + @override + Stream> getPeersStream() { + const peersChannel = EventChannel("nearby_service_peers"); + return peersChannel.receiveBroadcastStream().map((e) { + return NearbyDeviceMapper.instance.mapToDeviceList(e); + }); + } + + @override + Stream getConnectedDeviceStream(NearbyDevice device) { + const connectedDeviceChannel = EventChannel( + "nearby_service_connected_device", + ); + return connectedDeviceChannel.receiveBroadcastStream(device.info.id).map( + (e) => NearbyDeviceMapper.instance.mapToDevice(e), + ); + } +} diff --git a/lib/nearby_service_platform_interface.dart b/lib/nearby_service_platform_interface.dart new file mode 100644 index 0000000..c41b5ae --- /dev/null +++ b/lib/nearby_service_platform_interface.dart @@ -0,0 +1,59 @@ +import 'package:nearby_service/nearby_service.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'nearby_service_method_channel.dart'; + +abstract class NearbyServicePlatform extends PlatformInterface { + NearbyServicePlatform() : super(token: _token); + + static final Object _token = Object(); + + static NearbyServicePlatform _instance = MethodChannelNearbyService(); + + /// The default instance of [NearbyServicePlatform] to use. + /// + /// Defaults to [MethodChannelNearbyService]. + static NearbyServicePlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [NearbyServicePlatform] when + /// they register themselves. + static set instance(NearbyServicePlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } + + Future getPlatformModel() { + throw UnimplementedError('getPlatformModel() has not been implemented.'); + } + + Future getCurrentDevice() { + throw UnimplementedError('getCurrentDevice() has not been implemented.'); + } + + Future openServicesSettings() { + throw UnimplementedError( + 'openServicesSettings() has not been implemented.'); + } + + Future> getPeers() { + throw UnimplementedError('getPeers() has not been implemented.'); + } + + Stream> getPeersStream() { + throw UnimplementedError('streamPeers() has not been implemented.'); + } + + Stream getConnectedDeviceStream(NearbyDevice device) { + throw UnimplementedError( + 'getConnectedDeviceStream() has not been implemented.'); + } + + Future disconnect(NearbyDevice device) { + throw UnimplementedError('disconnect() has not been implemented.'); + } +} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart new file mode 100644 index 0000000..f38f5d7 --- /dev/null +++ b/lib/src/models/models.dart @@ -0,0 +1,3 @@ +export 'nearby_device.dart'; +export 'nearby_device_status.dart'; +export 'nearby_message.dart'; diff --git a/lib/src/models/nearby_device.dart b/lib/src/models/nearby_device.dart new file mode 100644 index 0000000..e3cbb72 --- /dev/null +++ b/lib/src/models/nearby_device.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:nearby_service/nearby_service.dart'; + +abstract class NearbyDevice { + static const unknown = 'unknown'; + + const NearbyDevice({required this.info, required this.status}); + + final NearbyDeviceInfo info; + final NearbyDeviceStatus status; + + T? byPlatform({ + T Function(NearbyDevice)? onAny, + T Function(NearbyAndroidDevice)? onAndroid, + T Function(NearbyIOSDevice)? onIOS, + }) { + if (this is NearbyIOSDevice && onIOS != null) { + return onIOS(this as NearbyIOSDevice); + } else if (this is NearbyAndroidDevice && onAndroid != null) { + return onAndroid(this as NearbyAndroidDevice); + } else { + return onAny?.call(this); + } + } +} + +abstract interface class NearbyDeviceMapper { + static NearbyDeviceMapper get instance { + if (Platform.isAndroid) { + return NearbyAndroidMapper(); + } + if (Platform.isIOS) { + return NearbyIOSMapper(); + } + + throw NearbyServiceException.unsupportedPlatform( + caller: 'NearbyDeviceMapper', + ); + } + + List mapToDeviceList(dynamic value); + + NearbyDevice? mapToDevice(dynamic value); +} + +class NearbyDeviceInfo { + const NearbyDeviceInfo({ + required this.displayName, + required this.id, + }); + + factory NearbyDeviceInfo.fromJson(Map? json) { + return NearbyDeviceInfo( + displayName: json?['displayName'] ?? NearbyDevice.unknown, + id: json?['id'] ?? NearbyDevice.unknown, + ); + } + + final String displayName; + final String id; + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + }; + } +} diff --git a/lib/src/models/nearby_device_status.dart b/lib/src/models/nearby_device_status.dart new file mode 100644 index 0000000..e594210 --- /dev/null +++ b/lib/src/models/nearby_device_status.dart @@ -0,0 +1,36 @@ +enum NearbyDeviceStatus { + available, + connected, + failed, + connecting, + unavailable; + + bool get isConnected => this == NearbyDeviceStatus.connected; + + static NearbyDeviceStatus fromAndroidCode(num? code) { + if (code == null) { + return NearbyDeviceStatus.failed; + } + return switch (code) { + (0) => NearbyDeviceStatus.connected, + (1) => NearbyDeviceStatus.connecting, + (2) => NearbyDeviceStatus.failed, + (3) => NearbyDeviceStatus.available, + (4) => NearbyDeviceStatus.unavailable, + (_) => NearbyDeviceStatus.failed, + }; + } + + static NearbyDeviceStatus fromIosCode(String? code) { + if (code == null) { + return NearbyDeviceStatus.failed; + } + final value = num.tryParse(code); + return switch (value) { + (0) => NearbyDeviceStatus.available, + (1) => NearbyDeviceStatus.connecting, + (2) => NearbyDeviceStatus.connected, + (_) => NearbyDeviceStatus.failed, + }; + } +} diff --git a/lib/src/models/nearby_message.dart b/lib/src/models/nearby_message.dart new file mode 100644 index 0000000..eb6f716 --- /dev/null +++ b/lib/src/models/nearby_message.dart @@ -0,0 +1,45 @@ +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/logger.dart'; + +abstract class NearbyMessage { + const NearbyMessage({required this.value}); + + final String value; +} + +class OutgoingNearbyMessage extends NearbyMessage { + const OutgoingNearbyMessage({ + required super.value, + required this.receiver, + }); + + Map toJson() { + return { + 'message': value, + 'receiver': receiver.toJson(), + }; + } + + final NearbyDeviceInfo receiver; +} + +class ReceivedNearbyMessage extends NearbyMessage { + const ReceivedNearbyMessage({ + required super.value, + required this.sender, + }); + + factory ReceivedNearbyMessage.fromJson(Map? json) { + try { + return ReceivedNearbyMessage( + value: json?['message'] ?? '', + sender: NearbyDeviceInfo.fromJson(json?['sender']), + ); + } catch (e) { + Logger.error(e); + throw NearbyServiceException('Can\'t map to device $json'); + } + } + + final NearbyDeviceInfo sender; +} diff --git a/lib/src/platforms/android/android.dart b/lib/src/platforms/android/android.dart new file mode 100644 index 0000000..11a65e6 --- /dev/null +++ b/lib/src/platforms/android/android.dart @@ -0,0 +1,4 @@ +export 'nearby_android_service.dart'; +export 'nearby_service_android_interface.dart'; +export 'models/nearby_connection_info.dart'; +export 'models/nearby_device.dart'; diff --git a/lib/src/platforms/android/models/nearby_connection_info.dart b/lib/src/platforms/android/models/nearby_connection_info.dart new file mode 100644 index 0000000..5f6bf9b --- /dev/null +++ b/lib/src/platforms/android/models/nearby_connection_info.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; + +class NearbyConnectionAndroidInfo { + static const unknown = 'unknown'; + + const NearbyConnectionAndroidInfo({ + required this.ownerIpAddress, + required this.groupFormed, + required this.isGroupOwner, + }); + + factory NearbyConnectionAndroidInfo.fromJson(Map json) { + final ownerIpAddress = (json['groupOwnerAddress'] ?? unknown) as String; + return NearbyConnectionAndroidInfo( + ownerIpAddress: ownerIpAddress.replaceFirst('/', ''), + groupFormed: json['groupFormed'] ?? false, + isGroupOwner: json['isGroupOwner'] ?? false, + ); + } + + final String ownerIpAddress; + final bool isGroupOwner; + final bool groupFormed; +} + +class NearbyConnectionInfoMapper { + NearbyConnectionInfoMapper._(); + + static NearbyConnectionAndroidInfo? mapToInfo(dynamic value) { + final jsonValue = jsonDecode(value) as Map?; + if (jsonValue == null) { + return null; + } + return NearbyConnectionAndroidInfo.fromJson(jsonValue); + } +} diff --git a/lib/src/platforms/android/models/nearby_device.dart b/lib/src/platforms/android/models/nearby_device.dart new file mode 100644 index 0000000..5aa2ee9 --- /dev/null +++ b/lib/src/platforms/android/models/nearby_device.dart @@ -0,0 +1,63 @@ +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/decoder.dart'; + +class NearbyAndroidDevice extends NearbyDevice { + NearbyAndroidDevice({ + required String deviceName, + required super.status, + required this.deviceAddress, + required this.isGroupOwner, + required this.isServiceDiscoveryCapable, + required this.primaryDeviceType, + required this.wpsKeypadSupported, + required this.wpsPbcSupported, + required this.wpsDisplaySupported, + this.secondaryDeviceType, + }) : super( + info: NearbyDeviceInfo( + displayName: deviceName, + id: deviceAddress, + ), + ); + + factory NearbyAndroidDevice.fromJson(Map? json) { + return NearbyAndroidDevice( + deviceName: json?['deviceName'] ?? NearbyDevice.unknown, + deviceAddress: json?['deviceAddress'] ?? NearbyDevice.unknown, + isGroupOwner: json?['isGroupOwner'] ?? false, + isServiceDiscoveryCapable: json?['isServiceDiscoveryCapable'] ?? false, + primaryDeviceType: json?['primaryDeviceType'] ?? NearbyDevice.unknown, + secondaryDeviceType: json?['secondaryDeviceType'], + wpsDisplaySupported: json?['wpsDisplaySupported'] ?? false, + wpsKeypadSupported: json?['wpsKeypadSupported'] ?? false, + wpsPbcSupported: json?['wpsPbcSupported'] ?? false, + status: NearbyDeviceStatus.fromAndroidCode(json?['status']), + ); + } + + final String deviceAddress; + final bool isGroupOwner; + final bool isServiceDiscoveryCapable; + final String primaryDeviceType; + final String? secondaryDeviceType; + final bool wpsKeypadSupported; + final bool wpsPbcSupported; + final bool wpsDisplaySupported; +} + +class NearbyAndroidMapper implements NearbyDeviceMapper { + @override + List mapToDeviceList(dynamic value) { + final decoded = Decoder.decodeList(value); + return [ + ...?decoded?.map( + (e) => NearbyAndroidDevice.fromJson(e as Map?), + ), + ]; + } + + @override + NearbyDevice? mapToDevice(dynamic value) { + return NearbyAndroidDevice.fromJson(Decoder.decodeMap(value)); + } +} diff --git a/lib/src/platforms/android/nearby_android_service.dart b/lib/src/platforms/android/nearby_android_service.dart new file mode 100644 index 0000000..122e9ca --- /dev/null +++ b/lib/src/platforms/android/nearby_android_service.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; + +import 'socket_service/nearby_socket_service.dart'; + +class NearbyAndroidService extends NearbyService { + late final _socketService = NearbySocketService(this); + + @override + ValueListenable get isCommunicationChannelConnecting { + return _socketService.isConnecting; + } + + NearbyConnectionAndroidInfo? get connectionInfo { + return _socketService.connectionInfo; + } + + @override + Future initialize({ + NearbyInitializeData data = const NearbyInitializeData(), + }) { + return NearbyServiceAndroidPlatform.instance.initialize(); + } + + @override + Future discover() { + return NearbyServiceAndroidPlatform.instance.discover(); + } + + @override + Future stopDiscovery() { + return NearbyServiceAndroidPlatform.instance.stopDiscovery(); + } + + @override + Future connect(NearbyDevice device) { + _requireAndroidDevice(device); + return NearbyServiceAndroidPlatform.instance.connect(device.info.id); + } + + @override + Future disconnect(NearbyDevice device) { + _requireAndroidDevice(device); + return NearbyServiceAndroidPlatform.instance.disconnect(device.info.id); + } + + Future requestPermissions() { + return NearbyServiceAndroidPlatform.instance.requestPermissions(); + } + + Future checkWifiService() { + return NearbyServiceAndroidPlatform.instance.checkWifiService(); + } + + Future getConnectionInfo() { + return NearbyServiceAndroidPlatform.instance.getConnectionInfo(); + } + + @override + FutureOr startCommunicationChannel( + NearbyCommunicationChannelData data, + ) { + return _socketService.startSocket(data: data); + } + + @override + FutureOr endCommunicationChannel() { + return _socketService.cancel(); + } + + @override + FutureOr send(OutgoingNearbyMessage message) { + return _socketService.send(message); + } + + void _requireAndroidDevice(NearbyDevice device) { + assert( + device is NearbyAndroidDevice, + 'The Nearby Android Service can only work with the NearbyAndroidDevice and not with ${device.runtimeType}', + ); + } +} diff --git a/lib/src/platforms/android/nearby_service_android_interface.dart b/lib/src/platforms/android/nearby_service_android_interface.dart new file mode 100644 index 0000000..26a44fb --- /dev/null +++ b/lib/src/platforms/android/nearby_service_android_interface.dart @@ -0,0 +1,57 @@ +import 'package:nearby_service/src/platforms/android/models/nearby_connection_info.dart'; +import 'package:nearby_service/src/platforms/android/nearby_service_android_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +abstract class NearbyServiceAndroidPlatform extends PlatformInterface { + NearbyServiceAndroidPlatform() : super(token: _token); + + static final Object _token = Object(); + + static NearbyServiceAndroidPlatform _instance = + MethodChannelAndroidNearbyService(); + + /// The default instance of [NearbyServiceAndroidPlatform] to use. + /// + /// Defaults to [NearbyServiceAndroidPlatform]. + static NearbyServiceAndroidPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [NearbyServiceAndroidPlatform] when + /// they register themselves. + static set instance(NearbyServiceAndroidPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future initialize() { + throw UnimplementedError('initialize() has not been implemented.'); + } + + Future requestPermissions() { + throw UnimplementedError('requestPermissions() has not been implemented.'); + } + + Future checkWifiService() { + throw UnimplementedError('checkWifiService() has not been implemented.'); + } + + Future getConnectionInfo() { + throw UnimplementedError('getConnectionInfo() has not been implemented.'); + } + + Future discover() { + throw UnimplementedError('discover() has not been implemented.'); + } + + Future stopDiscovery() { + throw UnimplementedError('stopDiscovery() has not been implemented.'); + } + + Future connect(String deviceAddress) { + throw UnimplementedError('connect() has not been implemented.'); + } + + Future disconnect(String deviceAddress) { + throw UnimplementedError('disconnect() has not been implemented.'); + } +} diff --git a/lib/src/platforms/android/nearby_service_android_method_channel.dart b/lib/src/platforms/android/nearby_service_android_method_channel.dart new file mode 100644 index 0000000..2d92c69 --- /dev/null +++ b/lib/src/platforms/android/nearby_service_android_method_channel.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/logger.dart'; + +/// An implementation of [NearbyServiceAndroidPlatform] that uses method channels. +class MethodChannelAndroidNearbyService extends NearbyServiceAndroidPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('nearby_service'); + + @override + Future initialize() async { + return (await methodChannel.invokeMethod( + 'initialize', + {"logLevel": Logger.level.name}, + )) ?? + false; + } + + @override + Future requestPermissions() async { + return (await methodChannel.invokeMethod('requestPermissions')) ?? + false; + } + + @override + Future checkWifiService() async { + return (await methodChannel.invokeMethod('checkWifiService')) ?? + false; + } + + @override + Future getConnectionInfo() async { + return NearbyConnectionInfoMapper.mapToInfo( + await methodChannel.invokeMethod('getConnectionInfo'), + ); + } + + @override + Future discover() async { + return (await methodChannel.invokeMethod('discover')) ?? false; + } + + @override + Future stopDiscovery() async { + return (await methodChannel.invokeMethod('stopDiscovery')) ?? false; + } + + @override + Future connect(String deviceAddress) async { + return (await methodChannel.invokeMethod( + "connect", + {"deviceAddress": deviceAddress}, + )) ?? + false; + } + + @override + Future disconnect(String deviceAddress) async { + return (await methodChannel.invokeMethod( + "disconnect", + {"deviceAddress": deviceAddress}, + )) ?? + false; + } +} diff --git a/lib/src/platforms/android/socket_service/nearby_socket_service.dart b/lib/src/platforms/android/socket_service/nearby_socket_service.dart new file mode 100644 index 0000000..7f58207 --- /dev/null +++ b/lib/src/platforms/android/socket_service/nearby_socket_service.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/logger.dart'; +import 'package:nearby_service/src/utils/stream_mapper.dart'; + +part 'ping_manager.dart'; + +part 'network.dart'; + +class NearbySocketService { + NearbySocketService(this._manager); + + final NearbyAndroidService _manager; + final _pingManager = NearbySocketPingManager(); + final _network = NearbyServiceNetwork(); + + final isConnecting = ValueNotifier(false); + NearbyConnectionAndroidInfo? connectionInfo; + + String? _connectedDeviceId; + WebSocket? _socket; + StreamSubscription? _messagesSubscription; + + Future startSocket({ + required NearbyCommunicationChannelData data, + }) async { + isConnecting.value = true; + _connectedDeviceId = data.connectedDeviceId; + connectionInfo = await _manager.getConnectionInfo(); + if (connectionInfo != null && connectionInfo!.groupFormed) { + final androidData = data.androidData; + if (connectionInfo!.isGroupOwner) { + await _startServerSubscription( + serverListener: androidData.serverListener, + socketListener: data.eventListener, + info: connectionInfo!, + port: androidData.port, + ); + return true; + } else { + await _tryConnectClient( + socketListener: data.eventListener, + reconnectInterval: androidData.clientReconnectInterval, + info: connectionInfo!, + port: androidData.port, + ); + return true; + } + } + return false; + } + + Future send(OutgoingNearbyMessage message) async { + if (_socket != null && message.receiver.id == _connectedDeviceId) { + final sender = await _manager.getCurrentDevice(); + if (sender != null) { + _socket!.add( + jsonEncode( + { + 'message': message.value, + 'sender': sender.info.toJson(), + }, + ), + ); + } + return true; + } + return false; + } + + Future cancel() async { + try { + isConnecting.value = false; + await _messagesSubscription?.cancel(); + _messagesSubscription = null; + _socket = null; + _connectedDeviceId = null; + return true; + } catch (e) { + return false; + } + } + + Future _tryConnectClient({ + required NearbyServiceStreamListener socketListener, + required NearbyConnectionAndroidInfo info, + required int port, + required Duration reconnectInterval, + }) async { + final response = await _network.pingServer( + address: info.ownerIpAddress, + port: port, + ); + + if (await _pingManager.checkPong(response)) { + _socket = await _network.connectToSocket( + ownerIpAddress: info.ownerIpAddress, + port: port, + ); + _createSocketSubscription(socketListener); + } else { + Logger.debug( + 'Retry to connect to the server in ${reconnectInterval.inSeconds}s', + ); + Future.delayed(reconnectInterval, () { + _tryConnectClient( + socketListener: socketListener, + reconnectInterval: reconnectInterval, + info: info, + port: port, + ); + }); + } + } + + Future _startServerSubscription({ + required NearbyServiceStreamListener socketListener, + required NearbyConnectionAndroidInfo info, + required int port, + ValueChanged? serverListener, + }) async { + final httpServer = await _network.startServer( + ownerIpAddress: info.ownerIpAddress, + port: port, + ); + httpServer?.listen( + (request) async { + serverListener?.call(request); + final isPing = await _pingManager.checkPing(request); + if (isPing) { + Logger.debug('Server got ping request'); + _network.pongClient(request); + return; + } + + if (request.uri.path == '/ws') { + _socket = await WebSocketTransformer.upgrade(request); + _createSocketSubscription(socketListener); + } else { + request.response + ..statusCode = HttpStatus.notFound + ..close(); + Logger.error('Got unknown request ${request.requestedUri}'); + } + }, + ); + } + + void _createSocketSubscription(NearbyServiceStreamListener socketListener) { + Logger.debug('Starting socket subscription'); + isConnecting.value = false; + if (_connectedDeviceId != null) { + _messagesSubscription = _socket + ?.map(MessagesStreamMapper.toMessage) + .where((event) => event != null) + .cast() + .map((e) => MessagesStreamMapper.replaceId(e, _connectedDeviceId!)) + .listen( + socketListener.onData, + onDone: socketListener.onDone, + onError: (e, s) { + Logger.error(e); + socketListener.onError?.call(e, s); + }, + cancelOnError: socketListener.cancelOnError, + ); + } + if (_messagesSubscription != null) { + Logger.info('Socket subscription was created successfully'); + socketListener.onCreated?.call(_messagesSubscription!); + } + } +} diff --git a/lib/src/platforms/android/socket_service/network.dart b/lib/src/platforms/android/socket_service/network.dart new file mode 100644 index 0000000..744dfbb --- /dev/null +++ b/lib/src/platforms/android/socket_service/network.dart @@ -0,0 +1,71 @@ +part of 'nearby_socket_service.dart'; + +class _Protocols { + static const http = 'http://'; + static const ws = 'ws://'; +} + +class NearbyServiceNetwork { + final _httpClient = HttpClient(); + final _random = Random(); + + Future pingServer({ + required String address, + required int port, + }) async { + try { + final url = Uri.parse('${_Protocols.http}$address:$port/'); + final request = (await _httpClient.postUrl(url)) + ..write(_Commands.ping.name); + final response = await request.close(); + return response; + } catch (e) { + Logger.error('Server is unreachable'); + return null; + } + } + + void pongClient(HttpRequest request) { + request.response + ..write(_Commands.pong.name) + ..close(); + Logger.debug('Sent pong to client'); + } + + Future startServer({ + required String ownerIpAddress, + required int port, + }) async { + try { + final url = 'ws://$ownerIpAddress:$port'; + Logger.debug('Starting server on $url'); + var server = await HttpServer.bind( + ownerIpAddress, + port, + shared: true, + ); + Logger.info('Server running on $url'); + return server; + } catch (e) { + Logger.error('Error starting socket: $e'); + return null; + } + } + + Future connectToSocket({ + required String ownerIpAddress, + required int port, + }) async { + try { + final connectionId = _random.nextInt(1000) + 100; + final url = '${_Protocols.ws}$ownerIpAddress:$port/ws?as=$connectionId'; + Logger.debug('Connecting to $url'); + final socket = await WebSocket.connect(url); + Logger.info('Connected to $url'); + return socket; + } catch (e) { + Logger.error('Error connecting to server: $e'); + return null; + } + } +} diff --git a/lib/src/platforms/android/socket_service/ping_manager.dart b/lib/src/platforms/android/socket_service/ping_manager.dart new file mode 100644 index 0000000..19086dd --- /dev/null +++ b/lib/src/platforms/android/socket_service/ping_manager.dart @@ -0,0 +1,25 @@ +part of 'nearby_socket_service.dart'; + +enum _Commands { ping, pong } + +class NearbySocketPingManager { + Future checkPing(HttpRequest request) async { + final body = await _getBody(request); + return body == _Commands.ping.name; + } + + Future checkPong(HttpClientResponse? response) async { + if (response == null) return false; + + final body = await _getBody(response); + return body == _Commands.pong.name; + } + + static Future _getBody(Stream> data) { + try { + return utf8.decoder.bind(data).join(); + } catch (e) { + return Future.value(null); + } + } +} diff --git a/lib/src/platforms/ios/ios.dart b/lib/src/platforms/ios/ios.dart new file mode 100644 index 0000000..4f4a012 --- /dev/null +++ b/lib/src/platforms/ios/ios.dart @@ -0,0 +1,3 @@ +export 'models/nearby_device.dart'; +export 'nearby_ios_service.dart'; +export 'nearby_service_ios_interface.dart'; diff --git a/lib/src/platforms/ios/models/nearby_device.dart b/lib/src/platforms/ios/models/nearby_device.dart new file mode 100644 index 0000000..751fd91 --- /dev/null +++ b/lib/src/platforms/ios/models/nearby_device.dart @@ -0,0 +1,45 @@ +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/decoder.dart'; + +class NearbyIOSDevice extends NearbyDevice { + NearbyIOSDevice({ + required super.info, + required super.status, + this.os, + this.osVersion, + this.deviceType, + }); + + factory NearbyIOSDevice.fromJson(Map? json) { + return NearbyIOSDevice( + info: NearbyDeviceInfo.fromJson(json), + deviceType: json?["deviceType"], + os: json?["os"], + osVersion: json?["osVersion"], + status: NearbyDeviceStatus.fromIosCode(json?['state']), + ); + } + + final String? os; + final String? osVersion; + final String? deviceType; +} + +class NearbyIOSMapper implements NearbyDeviceMapper { + @override + List mapToDeviceList(dynamic value) { + final decoded = Decoder.decodeList(value); + return [ + ...?decoded?.map( + (e) => NearbyIOSDevice.fromJson(e as Map?), + ), + ]; + } + + @override + NearbyDevice? mapToDevice(dynamic value) { + return NearbyIOSDevice.fromJson( + Decoder.decodeMap(value), + ); + } +} diff --git a/lib/src/platforms/ios/nearby_ios_service.dart b/lib/src/platforms/ios/nearby_ios_service.dart new file mode 100644 index 0000000..8cd4644 --- /dev/null +++ b/lib/src/platforms/ios/nearby_ios_service.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/logger.dart'; +import 'package:nearby_service/src/utils/stream_mapper.dart'; + +class NearbyIOSService extends NearbyService { + final _isBrowser = ValueNotifier(true); + final _isCommunicationChannelConnecting = ValueNotifier(false); + + StreamSubscription? _messagesSubscription; + + @override + ValueListenable get isCommunicationChannelConnecting => + _isCommunicationChannelConnecting; + + ValueListenable get isBrowser => _isBrowser; + + String get _currentConnectionType { + return _isBrowser.value ? 'browsing' : 'advertising'; + } + + @override + Future initialize({ + NearbyInitializeData data = const NearbyInitializeData(), + }) async { + final result = await NearbyServiceIOSPlatform.instance.initialize( + data.iosDeviceName, + ); + + _logResult( + result, + onSuccess: 'Initialized ${data.iosDeviceName}', + onError: 'Failed to initialize ${data.iosDeviceName}', + ); + return result; + } + + @override + Future discover() async { + final result = _isBrowser.value + ? await NearbyServiceIOSPlatform.instance.startBrowsing() + : await NearbyServiceIOSPlatform.instance.startAdvertising(); + _logResult( + result, + onSuccess: 'Started $_currentConnectionType', + onError: 'Failed to start $_currentConnectionType', + ); + return result; + } + + @override + Future stopDiscovery() async { + final result = _isBrowser.value + ? await NearbyServiceIOSPlatform.instance.stopBrowsing() + : await NearbyServiceIOSPlatform.instance.stopAdvertising(); + + _logResult( + result, + onSuccess: 'Stopped $_currentConnectionType', + onError: 'Failed to stop $_currentConnectionType', + ); + return result; + } + + @override + Future connect(NearbyDevice device) async { + _requireIOSDevice(device); + final result = _isBrowser.value + ? await NearbyServiceIOSPlatform.instance.invite(device.info.id) + : await NearbyServiceIOSPlatform.instance.acceptInvite(device.info.id); + + _logResult( + result, + onSuccess: + '${_isBrowser.value ? 'Sent invitation to' : 'Accepted invitation from'} ' + '${device.info.id}', + onError: 'Failed to connect to ${device.info.id}', + ); + return result; + } + + @override + Future disconnect(NearbyDevice device) async { + _requireIOSDevice(device); + final result = await NearbyServiceIOSPlatform.instance.disconnect( + device.info.id, + ); + _logResult( + result, + onSuccess: 'Disconnected from ${device.info.id}', + onError: 'Failed to disconnect from ${device.info.id}', + ); + return result; + } + + @override + FutureOr startCommunicationChannel( + NearbyCommunicationChannelData data, + ) async { + Logger.debug('Creating messages subscription'); + _isCommunicationChannelConnecting.value = true; + await endCommunicationChannel(); + final eventListener = data.eventListener; + _messagesSubscription = NearbyServiceIOSPlatform.instance.messagesStream + .map(MessagesStreamMapper.toMessage) + .where((event) => event?.sender.id == data.connectedDeviceId) + .where((event) => event != null) + .cast() + .listen( + eventListener.onData, + onDone: eventListener.onDone, + onError: (e, s) { + Logger.error(e); + eventListener.onError?.call(e, s); + }, + cancelOnError: eventListener.cancelOnError, + ); + if (_messagesSubscription != null) { + Logger.info('Messages subscription was created successfully'); + eventListener.onCreated?.call(_messagesSubscription!); + } + _isCommunicationChannelConnecting.value = false; + return true; + } + + @override + FutureOr endCommunicationChannel() async { + await _messagesSubscription?.cancel(); + _messagesSubscription = null; + Logger.debug('Communication channel was cancelled'); + return true; + } + + @override + Future send(OutgoingNearbyMessage message) { + return NearbyServiceIOSPlatform.instance.send(message); + } + + Future getSavedDeviceName() { + return NearbyServiceIOSPlatform.instance.getSavedDeviceName(); + } + + void setIsBrowser({required bool value}) { + Logger.debug('Is Browser Value was set to $value'); + _isBrowser.value = value; + } + + void _logResult( + bool value, { + required String onSuccess, + required String onError, + }) { + if (value) { + Logger.info(onSuccess); + } else { + Logger.error(onError); + } + } + + void _requireIOSDevice(NearbyDevice device) { + assert( + device is NearbyIOSDevice, + 'The Nearby IOS Service can only work with the NearbyIOSDevice and not with ${device.runtimeType}', + ); + } +} diff --git a/lib/src/platforms/ios/nearby_service_ios_interface.dart b/lib/src/platforms/ios/nearby_service_ios_interface.dart new file mode 100644 index 0000000..1d36778 --- /dev/null +++ b/lib/src/platforms/ios/nearby_service_ios_interface.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:nearby_service/nearby_service.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'nearby_service_ios_method_channel.dart'; + +abstract class NearbyServiceIOSPlatform extends PlatformInterface { + NearbyServiceIOSPlatform() : super(token: _token); + + static final Object _token = Object(); + + static NearbyServiceIOSPlatform _instance = MethodChannelIOSNearbyService(); + + /// The default instance of [NearbyServiceIOSPlatform] to use. + static NearbyServiceIOSPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [NearbyServiceIOSPlatform] when + /// they register themselves. + static set instance(NearbyServiceIOSPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Stream get messagesStream { + throw UnimplementedError('messagesStream() has not been implemented.'); + } + + Future initialize([String? deviceName]) { + throw UnimplementedError('initialize() has not been implemented.'); + } + + Future getSavedDeviceName() { + throw UnimplementedError('getSavedDeviceName() has not been implemented.'); + } + + Future startBrowsing() { + throw UnimplementedError('startBrowsing() has not been implemented.'); + } + + Future startAdvertising() { + throw UnimplementedError('startAdvertising() has not been implemented.'); + } + + Future stopBrowsing() { + throw UnimplementedError('stopBrowsing() has not been implemented.'); + } + + Future stopAdvertising() { + throw UnimplementedError('stopAdvertising() has not been implemented.'); + } + + Future invite(String deviceId) { + throw UnimplementedError('invite() has not been implemented.'); + } + + Future acceptInvite(String deviceId) { + throw UnimplementedError('acceptInvite() has not been implemented.'); + } + + Future disconnect(String deviceId) { + throw UnimplementedError('disconnect() has not been implemented.'); + } + + Future send(OutgoingNearbyMessage message) { + throw UnimplementedError('send() has not been implemented.'); + } +} diff --git a/lib/src/platforms/ios/nearby_service_ios_method_channel.dart b/lib/src/platforms/ios/nearby_service_ios_method_channel.dart new file mode 100644 index 0000000..de8188f --- /dev/null +++ b/lib/src/platforms/ios/nearby_service_ios_method_channel.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nearby_service/nearby_service.dart'; + +/// An implementation of [NearbyServiceIOSPlatform] that uses method channels. +class MethodChannelIOSNearbyService extends NearbyServiceIOSPlatform { + final messageReceiver = StreamController.broadcast(); + + @override + Stream get messagesStream => messageReceiver.stream; + + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('nearby_service'); + + @override + Future initialize([String? deviceName]) async { + methodChannel.setMethodCallHandler((handler) async { + switch (handler.method) { + case 'invoke_nearby_service_message_received': + messageReceiver.add(handler.arguments); + break; + } + }); + return (await methodChannel.invokeMethod( + 'initialize', + deviceName != null ? {"deviceName": deviceName} : null, + ) ?? + false); + } + + @override + Future getSavedDeviceName() async { + return (await methodChannel.invokeMethod('getSavedDeviceName')); + } + + @override + Future startAdvertising() async { + return (await methodChannel.invokeMethod('startAdvertising')) ?? + false; + } + + @override + Future startBrowsing() async { + return (await methodChannel.invokeMethod('startBrowsing')) ?? false; + } + + @override + Future stopAdvertising() async { + return (await methodChannel.invokeMethod('stopAdvertising')) ?? false; + } + + @override + Future stopBrowsing() async { + return (await methodChannel.invokeMethod('stopBrowsing')) ?? false; + } + + @override + Future invite(String deviceId) async { + return (await methodChannel.invokeMethod( + "invite", + {"deviceId": deviceId}, + )) ?? + false; + } + + @override + Future acceptInvite(String deviceId) async { + return (await methodChannel.invokeMethod( + "acceptInvite", + {"deviceId": deviceId}, + )) ?? + false; + } + + @override + Future disconnect(String deviceId) async { + return (await methodChannel.invokeMethod( + "disconnect", + {"deviceId": deviceId}, + )) ?? + false; + } + + @override + Future send(OutgoingNearbyMessage message) async { + return (await methodChannel.invokeMethod( + "send", + message.toJson(), + )) ?? + false; + } +} diff --git a/lib/src/types/communication_channel_data.dart b/lib/src/types/communication_channel_data.dart new file mode 100644 index 0000000..1af136a --- /dev/null +++ b/lib/src/types/communication_channel_data.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; + +class NearbyCommunicationChannelData { + const NearbyCommunicationChannelData( + this.connectedDeviceId, { + required this.eventListener, + this.androidData = const NearbyAndroidCommunicationChannelData(), + }); + + final String connectedDeviceId; + final NearbyServiceStreamListener eventListener; + final NearbyAndroidCommunicationChannelData androidData; +} + +class NearbyAndroidCommunicationChannelData { + const NearbyAndroidCommunicationChannelData({ + this.clientReconnectInterval = const Duration(seconds: 5), + this.serverListener, + this.port = 4045, + }); + + final Duration clientReconnectInterval; + final ValueChanged? serverListener; + final int port; +} diff --git a/lib/src/types/initialize_data.dart b/lib/src/types/initialize_data.dart new file mode 100644 index 0000000..3f82e9a --- /dev/null +++ b/lib/src/types/initialize_data.dart @@ -0,0 +1,5 @@ +class NearbyInitializeData { + const NearbyInitializeData({this.iosDeviceName}); + + final String? iosDeviceName; +} diff --git a/lib/src/types/nearby_service_stream_listener.dart b/lib/src/types/nearby_service_stream_listener.dart new file mode 100644 index 0000000..89c0c64 --- /dev/null +++ b/lib/src/types/nearby_service_stream_listener.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class NearbyServiceStreamListener { + const NearbyServiceStreamListener({ + required this.onData, + this.onCreated, + this.onDone, + this.onError, + this.cancelOnError, + }); + + final ValueChanged onData; + final ValueChanged>? onCreated; + final VoidCallback? onDone; + final void Function(Object, [StackTrace])? onError; + final bool? cancelOnError; +} diff --git a/lib/src/types/types.dart b/lib/src/types/types.dart new file mode 100644 index 0000000..a55a772 --- /dev/null +++ b/lib/src/types/types.dart @@ -0,0 +1,3 @@ +export 'communication_channel_data.dart'; +export 'nearby_service_stream_listener.dart'; +export 'initialize_data.dart'; diff --git a/lib/src/utils/decoder.dart b/lib/src/utils/decoder.dart new file mode 100644 index 0000000..5230833 --- /dev/null +++ b/lib/src/utils/decoder.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +import 'package:nearby_service/nearby_service.dart'; + +class Decoder { + Decoder._(); + + static Map? decodeMap(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + return jsonDecode(value) as Map; + } + if (value is Map) { + return value; + } + throw NearbyServiceException.unsupportedDecoding(value); + } + + static List? decodeList(dynamic value) { + if (value == null) { + return null; + } + if (value is String) { + return jsonDecode(value) as List?; + } + if (value is List) { + return value; + } + throw NearbyServiceException.unsupportedDecoding(value); + } +} diff --git a/lib/src/utils/exception.dart b/lib/src/utils/exception.dart new file mode 100644 index 0000000..fee02f7 --- /dev/null +++ b/lib/src/utils/exception.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:nearby_service/src/utils/logger.dart'; + +class NearbyServiceException implements Exception { + NearbyServiceException(this.error) { + Logger.error(error); + } + + factory NearbyServiceException.unsupportedPlatform({required String caller}) { + return NearbyServiceException( + '$caller is not supported for platform ${Platform.operatingSystem}', + ); + } + + factory NearbyServiceException.unsupportedDecoding(dynamic value) { + return NearbyServiceException( + 'Got unknown value=$value with runtimeType=${value.runtimeType}', + ); + } + + final Object? error; +} diff --git a/lib/src/utils/log_level.dart b/lib/src/utils/log_level.dart new file mode 100644 index 0000000..ea112db --- /dev/null +++ b/lib/src/utils/log_level.dart @@ -0,0 +1,6 @@ +enum NearbyServiceLogLevel { + debug, + info, + error, + disabled, +} diff --git a/lib/src/utils/logger.dart b/lib/src/utils/logger.dart new file mode 100644 index 0000000..90f4836 --- /dev/null +++ b/lib/src/utils/logger.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:nearby_service/nearby_service.dart'; + +class Logger { + Logger._(); + + static NearbyServiceLogLevel level = + kDebugMode ? NearbyServiceLogLevel.debug : NearbyServiceLogLevel.error; + + static void debug(String message) { + if (level.index <= NearbyServiceLogLevel.debug.index) { + debugPrint( + _messageWrapper( + message, + androidColor: AndroidConsoleColor.grey, + iosIcon: IOSConsoleIcon.settings, + ), + ); + } + } + + static void info(String message) { + if (level.index <= NearbyServiceLogLevel.info.index) { + debugPrint( + _messageWrapper( + message, + androidColor: AndroidConsoleColor.green, + iosIcon: IOSConsoleIcon.success, + ), + ); + } + } + + static void error(Object? error) { + if (level.index <= NearbyServiceLogLevel.error.index) { + debugPrint( + _messageWrapper( + '$error', + androidColor: AndroidConsoleColor.red, + iosIcon: IOSConsoleIcon.error, + ), + ); + } + } + + static String _messageWrapper( + String message, { + required AndroidConsoleColor androidColor, + required IOSConsoleIcon iosIcon, + }) { + if (Platform.isAndroid) { + return '\x1B[${androidColor.value}m[NearbyService]: $message\x1B[0m'; + } + if (Platform.isIOS) { + return '[NearbyService ${iosIcon.value}]: $message '; + } + return message; + } +} + +enum AndroidConsoleColor { + grey(37), + green(32), + red(31); + + const AndroidConsoleColor(this.value); + + final int value; +} + +enum IOSConsoleIcon { + settings('πŸ› '), + success('βœ…'), + error('❌'); + + const IOSConsoleIcon(this.value); + + final String value; +} diff --git a/lib/src/utils/stream_mapper.dart b/lib/src/utils/stream_mapper.dart new file mode 100644 index 0000000..4d437ec --- /dev/null +++ b/lib/src/utils/stream_mapper.dart @@ -0,0 +1,31 @@ +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/src/utils/decoder.dart'; + +import 'logger.dart'; + +abstract class MessagesStreamMapper { + static ReceivedNearbyMessage replaceId( + ReceivedNearbyMessage message, + String id, + ) { + return ReceivedNearbyMessage( + value: message.value, + sender: NearbyDeviceInfo( + id: id, + displayName: message.sender.displayName, + ), + ); + } + + static ReceivedNearbyMessage? toMessage(dynamic event) { + try { + final decoded = Decoder.decodeMap(event); + return ReceivedNearbyMessage.fromJson(decoded); + } catch (e) { + Logger.debug( + 'Can\'t convert $event to ReceivedNearbyMessage', + ); + } + return null; + } +} diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart new file mode 100644 index 0000000..c61ad0e --- /dev/null +++ b/lib/src/utils/utils.dart @@ -0,0 +1,2 @@ +export 'exception.dart'; +export 'log_level.dart'; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..59f2c0c --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,31 @@ +name: nearby_service +description: Nearby Service Flutter Plugin for creating connections in a P2P network. With this plugin you can easily create any application for sharing information without the Internet. +version: 0.0.1 +homepage: https://github.com/ksenia312/nearby_service +publish_to: none +platforms: + android: + ios: + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + + +flutter: + plugin: + platforms: + android: + package: com.xenikii.nearby_service + pluginClass: NearbyServicePlugin + ios: + pluginClass: NearbyServicePlugin diff --git a/test/nearby_service_method_channel_test.dart b/test/nearby_service_method_channel_test.dart new file mode 100644 index 0000000..25f9b54 --- /dev/null +++ b/test/nearby_service_method_channel_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearby_service/nearby_service_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelNearbyService platform = MethodChannelNearbyService(); + const MethodChannel channel = MethodChannel('nearby_service'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + return switch (methodCall.method) { + 'getPlatformVersion' => 42, + _ => null, + }; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/test/nearby_service_test.dart b/test/nearby_service_test.dart new file mode 100644 index 0000000..60a0ae4 --- /dev/null +++ b/test/nearby_service_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nearby_service/nearby_service.dart'; +import 'package:nearby_service/nearby_service_platform_interface.dart'; +import 'package:nearby_service/nearby_service_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockNearbyServicePlatform + with MockPlatformInterfaceMixin + implements NearbyServicePlatform { + @override + Stream getConnectedDeviceStream(device) { + // TODO: implement getConnectedDeviceStream + throw UnimplementedError(); + } + + @override + Future> getPeers() { + // TODO: implement getPeers + throw UnimplementedError(); + } + + @override + Stream> getPeersStream() { + // TODO: implement getPeersStream + throw UnimplementedError(); + } + + @override + Future getPlatformModel() { + // TODO: implement getPlatformModel + throw UnimplementedError(); + } + + @override + Future getPlatformVersion() { + // TODO: implement getPlatformVersion + throw UnimplementedError(); + } + + @override + Future openServicesSettings() { + // TODO: implement openServicesSettings + throw UnimplementedError(); + } + + @override + Future disconnect(NearbyDevice device) { + // TODO: implement disconnect + throw UnimplementedError(); + } + + @override + Future getCurrentDevice() { + // TODO: implement getCurrentDevice + throw UnimplementedError(); + } +} + +void main() { + final NearbyServicePlatform initialPlatform = NearbyServicePlatform.instance; + + test('$MethodChannelNearbyService is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + NearbyService nearbyServicePlugin = NearbyService.getInstance(); + MockNearbyServicePlatform fakePlatform = MockNearbyServicePlatform(); + NearbyServicePlatform.instance = fakePlatform; + + expect(await nearbyServicePlugin.getPlatformVersion(), '42'); + }); +}