commit 541fbeaaf370b5188c50acaf41e9587a2c1c106a Author: ksenia312 Date: Wed Jan 31 14:16:22 2024 +0100 first commit 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 0000000..249e583 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/android/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-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 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ 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 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ 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 0000000..6ed2d93 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ 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 0000000..4cd7b00 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ 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 0000000..fe73094 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ 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 0000000..502f463 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ 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 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ 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 0000000..e9f5fea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ 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 0000000..0467bf1 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ 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 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ 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 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ 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 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ 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'); + }); +}