diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..1570329 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..90701f9 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index e1f20f9..d1da393 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,18 +4,19 @@ diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 8978d23..f682ab2 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,8 @@ + + + + diff --git a/build.gradle b/build.gradle index c4186bc..8ba3fb1 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,7 @@ plugins { id 'com.android.library' version '8.1.1' apply false id 'org.jetbrains.kotlin.android' version '1.9.0' apply false id 'org.jetbrains.kotlin.plugin.serialization' version "1.9.0" apply false + id 'org.jetbrains.kotlin.jvm' version '1.9.0' apply false } ext.buildConfigProperties = new Properties() @@ -16,7 +17,6 @@ group 'com.github.UstadMobile.Meshrabiya' version '0.1d11-snapshot' ext { - version_kotlin_mockito = "4.1.0" version_android_mockito = "5.1.1" version_turbine = "0.12.1" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..5ce36f5 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,90 @@ +[versions] +accompanistWebview = "0.34.0" +acraHttp = "5.11.3" +activityCompose = "1.8.2" +androidx_core = "1.12.0" +appcompat = "1.6.1" +codeScanner = "2.3.2" +coreKtx = "1.12.0" +ipaddress = "5.4.0" +junitVersion = "1.1.5" +kodeinDiFrameworkAndroidX = "7.21.2" +kotlinxCoroutinesTest = "1.7.3" +lifecycleRuntimeKtx = "2.7.0" +material = "1.12.0" +mockitoCore = "5.1.0" +mockitoCoreVersion = "5.1.1" +nanohttpd = "2.3.1" +navigationCompose = "2.7.7" +okhttp = "4.10.0" +okhttpVersion = "4.12.0" +rawhttp = "2.6.0" +kotlinx_serialization = "1.6.1" +datastore = "1.0.0" +bouncycastle = "1.75" +android_desugaring = "2.0.4" +junit = "4.13.2" +kotlin_mockito = "5.1.0" +turbine = "1.0.0" +mockwebserver = "4.12.0" +android_junit_runner = "1.5.2" +androidx_test_rules = "1.5.0" +android_test_ext_junit = "1.1.5" +espresso_core = "3.5.1" +play_services_nearby = "19.3.0" +zxingAndroidEmbedded = "4.3.0" + + +[libraries] +accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanistWebview" } +acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acraHttp" } +acra-http = { module = "ch.acra:acra-http", version.ref = "acraHttp" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidx_core" } +androidx-core-ktx-v1131 = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-material3 = { module = "androidx.compose.material3:material3" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-ui = { module = "androidx.compose.ui:ui" } +androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +code-scanner = { module = "com.github.yuriy-budiyev:code-scanner", version.ref = "codeScanner" } +core = { module = "androidx.core:core", version.ref = "coreKtx" } +google-material = { module = "com.google.android.material:material", version.ref = "material" } +ipaddress = { module = "com.github.seancfoley:ipaddress", version.ref = "ipaddress" } +kodein-di-framework-android-x = { module = "org.kodein.di:kodein-di-framework-android-x", version.ref = "kodeinDiFrameworkAndroidX" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +material3 = { module = "androidx.compose.material3:material3" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCoreVersion" } +nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +rawhttp = { group = "com.athaydes.rawhttp", name = "rawhttp-core", version.ref = "rawhttp" } +kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx_serialization" } +datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +bouncycastle-prov = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } +bouncycastle-pkix = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bouncycastle" } +desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "android_desugaring" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "kotlin_mockito" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } +android-junit-runner = { group = "androidx.test", name = "runner", version.ref = "android_junit_runner" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx_test_rules" } +android-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "android_test_ext_junit" } +mockito-android = { group = "org.mockito", name = "mockito-android", version.ref = "mockitoCore" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso_core" } +google-play-services-nearby = { group = "com.google.android.gms", name = "play-services-nearby", version.ref = "play_services_nearby" } +ui = { module = "androidx.compose.ui:ui" } +ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } diff --git a/lib-meshrabiya-vpn/.gitignore b/lib-meshrabiya-vpn/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/lib-meshrabiya-vpn/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lib-meshrabiya-vpn/build.gradle.kts b/lib-meshrabiya-vpn/build.gradle.kts new file mode 100644 index 0000000..1e52364 --- /dev/null +++ b/lib-meshrabiya-vpn/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.meshrabiya.lib_vpn" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.google.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/lib-meshrabiya-vpn/consumer-rules.pro b/lib-meshrabiya-vpn/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/lib-meshrabiya-vpn/proguard-rules.pro b/lib-meshrabiya-vpn/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/lib-meshrabiya-vpn/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/lib-meshrabiya-vpn/src/androidTest/java/com/meshrabiya/lib_vpn/ExampleInstrumentedTest.kt b/lib-meshrabiya-vpn/src/androidTest/java/com/meshrabiya/lib_vpn/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..1da64ad --- /dev/null +++ b/lib-meshrabiya-vpn/src/androidTest/java/com/meshrabiya/lib_vpn/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.meshrabiya.lib_vpn + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.meshrabiya.lib_vpn.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/lib-meshrabiya-vpn/src/main/AndroidManifest.xml b/lib-meshrabiya-vpn/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/lib-meshrabiya-vpn/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lib-meshrabiya-vpn/src/main/java/com/meshrabiya/lib_vpn/MeshrabiyaVpnService.kt b/lib-meshrabiya-vpn/src/main/java/com/meshrabiya/lib_vpn/MeshrabiyaVpnService.kt new file mode 100644 index 0000000..5fa3d1c --- /dev/null +++ b/lib-meshrabiya-vpn/src/main/java/com/meshrabiya/lib_vpn/MeshrabiyaVpnService.kt @@ -0,0 +1,364 @@ +package com.meshrabiya.lib_vpn + + +import android.content.Intent +import android.net.VpnService +import android.os.ParcelFileDescriptor +import android.system.OsConstants +import android.util.Log +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.net.InetAddress +import java.nio.ByteBuffer +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class MeshrabiyaVpnService : VpnService() { + private var vpnInterface: ParcelFileDescriptor? = null + private lateinit var fileInputStream: FileInputStream + private lateinit var fileOutputStream: FileOutputStream + private val builder = Builder() + private val executorService: ExecutorService = Executors.newSingleThreadExecutor() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + logMessage("VPN Service started") + setupVpn() + return START_STICKY + } + + /** + * Configures the VPN connection with address, routes, DNS servers, and session settings. + * Establishes the VPN interface and sets up input and output streams. + */ + private fun setupVpn() { + try { + builder.apply { + addAddress(VPN_ADDRESS, VPN_ADDRESS_PREFIX_LENGTH) + addRoute(ROUTE_ADDRESS, ROUTE_PREFIX_LENGTH) + addDnsServer(PRIMARY_DNS) + addDnsServer(SECONDARY_DNS) + setSession(VPN_SESSION_NAME) + setMtu(MTU_VALUE) + allowFamily(OsConstants.AF_INET) + allowFamily(OsConstants.AF_INET6) + } + + vpnInterface = builder.establish() + + if (vpnInterface == null) { + logMessage("Failed to establish VPN interface: vpnInterface is null") + throw IllegalStateException("Failed to establish VPN interface.") + } + + fileInputStream = FileInputStream(vpnInterface!!.fileDescriptor) + fileOutputStream = FileOutputStream(vpnInterface!!.fileDescriptor) + logMessage("VPN interface established successfully") + startHandlingTraffic() + + } catch (e: Exception) { + logMessage("Error setting up VPN: ${e.message ?: "Unknown error"}") + } + } + + /** + * Starts a new thread to handle traffic. + */ + private fun startHandlingTraffic() { + executorService.submit { handleTraffic() } + } + + /** + * Continuously reads and processes traffic from the VPN interface. + */ + private fun handleTraffic() { + val buffer = ByteBuffer.allocate(BUFFER_SIZE) + while (true) { + try { + buffer.clear() + val length = readFromVpnInterface(buffer) + if (length > 0) { + processPacket(buffer, length) + } + } catch (e: IOException) { + logMessage("Error handling traffic: ${e.message}") + break + } + } + } + + /** + * Reads data from the VPN interface into the provided buffer. + * @return the number of bytes read or -1 in case of an error. + */ + private fun readFromVpnInterface(buffer: ByteBuffer): Int { + return try { + fileInputStream.channel.read(buffer) + } catch (e: IOException) { + logMessage("Error reading from VPN interface: ${e.message}") + -1 + } + } + + /** + * Processes a packet by determining its IP version and handling accordingly. + * @param buffer the buffer containing the packet data. + * @param length the length of the packet data. + */ + private fun processPacket(buffer: ByteBuffer, length: Int) { + // The `flip()` method gets the buffer ready to read data. + // When we write to the buffer, it moves a pointer forward. + // `flip()` changes the buffer from writing mode to reading mode. + // It sets the end (limit) to where we stopped writing and resets the start (position) to zero, + // so we can read the data from the beginning. + buffer.flip() + + // Check the first byte of the packet to figure out if it's an IPv4 or IPv6 packet. + // The first few bits of the first byte tell us the IP version (4 or 6). + when (val ipVersion = (buffer.get(0).toInt() ushr 4).toByte()) { + IPV4_VERSION -> handleIPv4Packet(buffer, length) + IPV6_VERSION -> handleIPv6Packet(buffer, length) + else -> logMessage("Unsupported IP version: $ipVersion") + } + } + + /** + * Handles an IPv4 packet by extracting the destination IP and port, and processing it based on the network type. + * @param buffer the buffer containing the IPv4 packet data. + * @param length the length of the packet data. + */ + private fun handleIPv4Packet(buffer: ByteBuffer, length: Int) { + val destinationIp = buildIPv4Address(buffer) + val header = parseIPv4Header(buffer) + + logMessage("IPv4 Packet - Destination: ${header.destinationIp.hostAddress}:${header.destinationPort}, Protocol: ${header.protocolName}") + + if (isVirtualNetwork(destinationIp)) { + handleVirtualNetworkPacket(buffer, length) + } else { + writeToVpnInterface(buffer) + } + } + + /** + * Builds an IPv4 address from the provided buffer. + * @param buffer the buffer containing the packet data. + * @return the constructed IPv4 address. + */ + private fun buildIPv4Address(buffer: ByteBuffer): InetAddress { + return InetAddress.getByAddress(ByteArray(4).also { buffer.get(it, 0, 4) }) + } + + /** + * Parses the IPv4 header to extract important information such as destination port, origin port, and protocol. + * Returns an IpHeader data class containing all relevant information. + */ + private fun parseIPv4Header(buffer: ByteBuffer): IpHeader { + try { + val protocol = buffer.get(IPV4_PROTOCOL_OFFSET).toInt() + val isTcp = protocol == IPPROTO_TCP + val isUdp = protocol == IPPROTO_UDP + + val ipHeaderLength = (buffer.get(IPV4_HEADER_LENGTH_OFFSET).toInt() and 0x0F) * 4 + val destinationPort = if (isTcp || isUdp) { + ((buffer.get(ipHeaderLength + 2).toInt() and 0xFF) shl 8) or + (buffer.get(ipHeaderLength + 3).toInt() and 0xFF) + } else { + -1 + } + + val protocolName = when { + isTcp -> Protocol.TCP + isUdp -> Protocol.UDP + else -> Protocol.UNKNOWN + } + + return IpHeader( + destinationIp = buildIPv4Address(buffer), + destinationPort = destinationPort, + + // NOTE : ----> Placeholder, real origin logic needed + originIp = InetAddress.getByName("0.0.0.0"), + // Placeholder + originPort = -1, + + protocolName = protocolName + ) + } catch (e: Exception) { + logMessage("Error parsing IPv4 header: ${e.message}") + return IpHeader(InetAddress.getByName("0.0.0.0"), -1, InetAddress.getByName("0.0.0.0"), -1, Protocol.ERROR) + } + } + + /** + * Handles an IPv6 packet by extracting the destination IP and port, and processing it based on the network type. + * @param buffer the buffer containing the IPv6 packet data. + * @param length the length of the packet data. + */ + private fun handleIPv6Packet(buffer: ByteBuffer, length: Int) { + val destinationIp = buildIPv6Address(buffer) + val header = parseIPv6Header(buffer) + + logMessage("IPv6 Packet - Destination: [${header.destinationIp.hostAddress}]:${header.destinationPort}, Protocol: ${header.protocolName}") + + if (isVirtualNetwork(destinationIp)) { + handleVirtualNetworkPacket(buffer, length) + } else { + writeToVpnInterface(buffer) + } + } + + /** + * Builds an IPv6 address from the provided buffer. + * @param buffer the buffer containing the packet data. + * @return the constructed IPv6 address. + */ + private fun buildIPv6Address(buffer: ByteBuffer): InetAddress { + return InetAddress.getByAddress(ByteArray(16).also { buffer.get(it, 0, 16) }) + } + + /** + * Parses the IPv6 header to extract important information such as destination port, origin port, and protocol. + * Returns an IpHeader data class containing all relevant information. + */ + private fun parseIPv6Header(buffer: ByteBuffer): IpHeader { + try { + val protocol = buffer.get(IPV6_PROTOCOL_OFFSET).toInt() + val isTcp = protocol == IPPROTO_TCP + val isUdp = protocol == IPPROTO_UDP + + val destinationPort = if (isTcp || isUdp) { + ((buffer.get(IPV6_PORT_OFFSET).toInt() and 0xFF) shl 8) or + (buffer.get(IPV6_PORT_OFFSET + 1).toInt() and 0xFF) + } else { + -1 + } + + val protocolName = when { + isTcp -> Protocol.TCP + isUdp -> Protocol.UDP + else -> Protocol.UNKNOWN + } + + return IpHeader( + destinationIp = buildIPv6Address(buffer), + destinationPort = destinationPort, + + // NOTE : ----> Placeholder, real origin logic needed + originIp = InetAddress.getByName("::"), + // Placeholder + originPort = -1, + protocolName = protocolName + ) + } catch (e: Exception) { + logMessage("Error parsing IPv6 header: ${e.message}") + return IpHeader(InetAddress.getByName("::"), -1, InetAddress.getByName("::"), -1, Protocol.ERROR) + } + } + + /** + * Checks if the destination IP is part of the virtual network. + * @param destinationIp the destination IP address. + * @return true if the IP is part of the virtual network, false otherwise. + */ + private fun isVirtualNetwork(destinationIp: InetAddress): Boolean { + return destinationIp.isSiteLocalAddress + } + + /** + * Handles packets that are destined for the virtual network. + * @param buffer the buffer containing the packet data. + * @param length the length of the packet data. + */ + private fun handleVirtualNetworkPacket(buffer: ByteBuffer, length: Int) { + logMessage("Handling packet for virtual network ") + } + + /** + * Writes the provided buffer data to the VPN interface. + * @param buffer the buffer containing the packet data. + */ + private fun writeToVpnInterface(buffer: ByteBuffer) { + try { + fileOutputStream.channel.write(buffer) + } catch (e: IOException) { + logMessage("Error writing to VPN interface: ${e.message}") + } + } + + override fun onRevoke() { + logMessage("VPN Service revoked") + try { + fileInputStream.close() + fileOutputStream.close() + vpnInterface?.close() + } catch (e: IOException) { + logMessage("Error closing resources: ${e.message}") + } + super.onRevoke() + } + + private fun logMessage(message: String) { + Log.d(TAG, message) + } + + companion object { + private const val TAG = "MeshrabiyaVpnService" + private const val VPN_SESSION_NAME = "MeshrabiyaVPN" + + // VPN_ADDRESS is the internal IP address used by the VPN for routing data within the VPN network. + private val VPN_ADDRESS = InetAddress.getByName("10.0.0.2") + + // VPN_ADDRESS_PREFIX_LENGTH specifies the subnet mask for the VPN network. A value of 24 means the VPN uses a 255.255.255.0 subnet mask, allowing for up to 256 IP addresses. + private const val VPN_ADDRESS_PREFIX_LENGTH = 24 + + // ROUTE_ADDRESS is the IP address range for routing local network traffic. It’s used to determine which traffic should go through the local network. + private val ROUTE_ADDRESS = InetAddress.getByName("192.168.0.0") + + // ROUTE_PREFIX_LENGTH specifies the subnet mask for local network routing. Similar to VPN_ADDRESS_PREFIX_LENGTH, this mask defines how many addresses are in the local network. + private const val ROUTE_PREFIX_LENGTH = 24 + + // PRIMARY_DNS is the IP address of the main DNS server used by the VPN for resolving domain names to IP addresses. + private val PRIMARY_DNS = InetAddress.getByName("1.1.1.1") + + // SECONDARY_DNS is the IP address of a backup DNS server. If the primary DNS server is unavailable, the VPN will use this server instead. + private val SECONDARY_DNS = InetAddress.getByName("8.8.8.8") + + // MTU_VALUE stands for Maximum Transmission Unit. It defines the largest size of a packet that can be sent through the VPN without needing to be fragmented. 1500 bytes is a common default size. + private const val MTU_VALUE = 1500 + + // BUFFER_SIZE determines the amount of data that can be read or written at once. 32767 bytes is a size chosen to handle large packets efficiently. + private const val BUFFER_SIZE = 32767 + + private const val IPV4_VERSION: Byte = 4 + private const val IPV6_VERSION: Byte = 6 + + // Protocol numbers for different types of network traffic, based on the official IP protocol specifications. + // These numbers identify whether a packet is using TCP, UDP, or ICMPv6 protocol. + private const val IPPROTO_TCP = 6 // TCP is protocol number 6 (used for web traffic, file transfer, etc.) + private const val IPPROTO_UDP = 17 // UDP is protocol number 17 (used for streaming, video games, etc.) + + private const val IPV4_PROTOCOL_OFFSET = 9 // For IPv4 packets, the type of protocol (e.g., TCP, UDP) is found at the 10th byte of the header. This is why the offset is 9. + private const val IPV4_HEADER_LENGTH_OFFSET = 0 // The length of the IPv4 header itself is encoded in the first byte, but only in the first 4 bits. This is why we use offset 0. + private const val IPV6_PROTOCOL_OFFSET = 6 // For IPv6 packets, the protocol type is found in the 7th byte of the header, which is why the offset is 6. + private const val IPV6_PORT_OFFSET = 40 // In IPv6 packets, information about source and destination ports starts after the first 40 bytes of the header, hence the offset is 40. + } +} + +/** + * Enum class representing the valid protocols (TCP, UDP, ICMPv6, and Unknown). + */ +enum class Protocol { + TCP, UDP, UNKNOWN, ERROR +} + +/** + * Data class to represent the parsed IP header with necessary information. + */ +data class IpHeader( + val destinationIp: InetAddress, + val destinationPort: Int, + val originIp: InetAddress, + val originPort: Int, + val protocolName: Protocol +) diff --git a/lib-meshrabiya-vpn/src/test/java/com/meshrabiya/lib_vpn/ExampleUnitTest.kt b/lib-meshrabiya-vpn/src/test/java/com/meshrabiya/lib_vpn/ExampleUnitTest.kt new file mode 100644 index 0000000..664aeb3 --- /dev/null +++ b/lib-meshrabiya-vpn/src/test/java/com/meshrabiya/lib_vpn/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.meshrabiya.lib_vpn + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/lib-meshrabiya/build.gradle b/lib-meshrabiya/build.gradle index 1a27d73..f94f5b4 100644 --- a/lib-meshrabiya/build.gradle +++ b/lib-meshrabiya/build.gradle @@ -37,37 +37,39 @@ android { } dependencies { - implementation "androidx.core:core-ktx:$version_androidx_core" - implementation "androidx.appcompat:appcompat:$version_appcompat" - implementation "com.athaydes.rawhttp:rawhttp-core:$version_rawhttp" + implementation(libs.google.play.services.nearby) + implementation libs.androidx.core + implementation libs.androidx.appcompat + implementation libs.rawhttp implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$version_kotlinx_serialization" - implementation "androidx.datastore:datastore-preferences:$version_datastore" + implementation libs.datastore - implementation "org.bouncycastle:bcprov-jdk18on:$version_bouncycastle" - implementation "org.bouncycastle:bcpkix-jdk18on:$version_bouncycastle" + implementation libs.bouncycastle.prov + implementation libs.bouncycastle.pkix - implementation "com.github.seancfoley:ipaddress:$version_ip_address" + implementation libs.ipaddress coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$version_android_desugaring" testImplementation project(":test-shared") - testImplementation "junit:junit:$version_junit" - testImplementation "org.mockito.kotlin:mockito-kotlin:$version_kotlin_mockito" + + testImplementation libs.junit + testImplementation libs.mockito.kotlin testImplementation "app.cash.turbine:turbine:$version_turbine" - testImplementation "com.squareup.okhttp3:mockwebserver:$version_mockwebserver" - testImplementation "com.squareup.okhttp3:okhttp:$version_okhttp" + testImplementation libs.mockwebserver + testImplementation libs.okhttp //As per: https://developer.android.com/topic/libraries/testing-support-library/packages.html#gradle-dependencies - androidTestImplementation "androidx.test:runner:$version_android_junit_runner" - androidTestImplementation "androidx.test:rules:$version_androidx_test_rules" + androidTestImplementation libs.android.junit.runner + androidTestImplementation libs.androidx.test.rules androidTestImplementation project(":test-shared") - androidTestImplementation "androidx.test.ext:junit:$version_android_test_ext_junit" + androidTestImplementation libs.androidx.junit androidTestImplementation "app.cash.turbine:turbine:$version_turbine" - androidTestImplementation "org.mockito:mockito-android:$version_android_mockito" - androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$version_kotlin_mockito" + androidTestImplementation libs.mockito.android + androidTestImplementation libs.mockito.kotlin } publishing { diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/OriginatingMessageManager.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/OriginatingMessageManager.kt index 09c9086..07eddcb 100644 --- a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/OriginatingMessageManager.kt +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/OriginatingMessageManager.kt @@ -2,12 +2,14 @@ package com.ustadmobile.meshrabiya.vnet import android.util.Log import com.ustadmobile.meshrabiya.ext.addressToDotNotation +import com.ustadmobile.meshrabiya.ext.asInetAddress import com.ustadmobile.meshrabiya.ext.requireAddressAsInt import com.ustadmobile.meshrabiya.log.MNetLogger import com.ustadmobile.meshrabiya.mmcp.MmcpOriginatorMessage import com.ustadmobile.meshrabiya.mmcp.MmcpPing import com.ustadmobile.meshrabiya.mmcp.MmcpPong import com.ustadmobile.meshrabiya.vnet.VirtualPacket.Companion.ADDR_BROADCAST +import com.ustadmobile.meshrabiya.vnet.netinterface.VirtualNetworkInterface import com.ustadmobile.meshrabiya.vnet.socket.ChainSocketNextHop import com.ustadmobile.meshrabiya.vnet.wifi.state.MeshrabiyaWifiState import kotlinx.coroutines.CoroutineScope @@ -22,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.net.DatagramPacket @@ -33,8 +36,12 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +/** + * @param virtualNetworkInterfaces function that provide a list of current virtual ip addresses for node. + */ + class OriginatingMessageManager( - localNodeInetAddr: InetAddress, + private val virtualNetworkInterfaces: () -> List, private val logger: MNetLogger, private val scheduledExecutorService: ScheduledExecutorService, private val nextMmcpMessageId: () -> Int, @@ -44,23 +51,22 @@ class OriginatingMessageManager( lostNodeCheckInterval: Int = 1_000, ) { - private val logPrefix ="[OriginatingMessageManager for ${localNodeInetAddr}] " + private val logPrefix = "[OriginatingMessageManager for ${virtualNetworkInterfaces}] " private val scope = CoroutineScope(Dispatchers.IO + Job()) - private val localNodeAddress = localNodeInetAddr.requireAddressAsInt() - /** * The currently known latest originator messages that can be used to route traffic. */ - private val originatorMessages: MutableMap = ConcurrentHashMap() + private val originatorMessages: MutableMap = + ConcurrentHashMap() private val _state = MutableStateFlow>(emptyMap()) val state: Flow> = _state.asStateFlow() private val receivedMessages: Flow = MutableSharedFlow( - replay = 1 , extraBufferCapacity = 0, onBufferOverflow = BufferOverflow.DROP_OLDEST, + replay = 1, extraBufferCapacity = 0, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) data class PendingPing( @@ -84,90 +90,108 @@ class OriginatingMessageManager( logger( priority = Log.VERBOSE, - message = { "$logPrefix sending originating message " + - "messageId=${originatingMessage.messageId} sentTime=${originatingMessage.sentTime}" + message = { + "$logPrefix sending originating message " + + "messageId=${originatingMessage.messageId} sentTime=${originatingMessage.sentTime}" } ) - val packet = originatingMessage.toVirtualPacket( - toAddr = ADDR_BROADCAST, - fromAddr = localNodeAddress, - lastHopAddr = localNodeAddress, - hopCount = 1, - ) - val neighbors = originatorMessages.filter { - it.value.hopCount == 1.toByte() - } + //TODO: This should be a loop - it should go over all known addresses and broadcast for each one + //It should then go over each INTERFACE and send the broadcast - the interface is responsible + //to know who the neighbors are - neighbors.forEach { - val lastOriginatorMessage = it.value - try { - lastOriginatorMessage.receivedFromSocket.send( - nextHopAddress = lastOriginatorMessage.lastHopRealInetAddr, - nextHopPort = lastOriginatorMessage.lastHopRealPort, - virtualPacket = packet, + val networkInterfaces = virtualNetworkInterfaces() + val addresses = networkInterfaces.map { it.virtualAddress } + + networkInterfaces.forEach { virtualNetworkInterface -> + + val networkInterfaceAddress = virtualNetworkInterface.virtualAddress.requireAddressAsInt() + + addresses.forEach { address-> + val packet = originatingMessage.toVirtualPacket( + toAddr = ADDR_BROADCAST, + fromAddr = address.requireAddressAsInt(), + lastHopAddr = networkInterfaceAddress, + hopCount = 1, ) - }catch(e: Exception) { - logger(Log.WARN, "$logPrefix : sendOriginatingMessagesRunnable: exception sending to " + - "${it.key.addressToDotNotation()} through ${it.value.lastHopRealInetAddr}:${it.value.lastHopRealPort}", - e) + + virtualNetworkInterface.send(packet, ADDR_BROADCAST.asInetAddress()) } } - //check if we have an active station connection but have lost the originating message from - // the hotspot node e.g. node slowed down for a while, app restart, etc. - //Send it an originating message even if we haven't receive one from it lately - //This could help restore a connection that died temporarily. - val stationState = getWifiState().wifiStationState - val stationNeighborInetAddr = stationState.config?.linkLocalAddr - val stationDatagramPort = stationState.config?.port - if(stationNeighborInetAddr != null && - !neighbors.any { it.value.lastHopRealInetAddr == stationNeighborInetAddr } - && stationDatagramPort != null - && stationState.stationBoundDatagramSocket != null - ) { - logger(Log.WARN, "$logPrefix : sendOriginatingMessagesRunnable: have not received " + - " originating message from hotspot we are connected to as station. Retrying") + + val neighbors = originatorMessages.filter { + it.value.hopCount == 1.toByte() + } + + neighbors.forEach { (neighborAddr, lastOriginatorMessage) -> try { - stationState.stationBoundDatagramSocket.send( - nextHopAddress = stationNeighborInetAddr, - nextHopPort = stationDatagramPort, + val outgoingAddr = selectOutgoingAddrForDestination(neighborAddr.asInetAddress()) + val packet = originatingMessage.toVirtualPacket( + toAddr = neighborAddr, + fromAddr = outgoingAddr.requireAddressAsInt(), + lastHopAddr = outgoingAddr.requireAddressAsInt(), + hopCount = 1, + ) + lastOriginatorMessage.receivedFromInterface?.send( virtualPacket = packet, + nextHopAddress = lastOriginatorMessage.lastHopAddr.asInetAddress() + ) + } catch (e: Exception) { + logger( + Log.WARN, + "$logPrefix : sendOriginatingMessagesRunnable: exception sending to ${neighborAddr.addressToDotNotation()}", + e ) - }catch(e: Exception) { - logger(Log.ERROR, "$logPrefix : sendOriginatingMessagesRunnable: could not " + - "send originating message to group owner", e) } - }else if(stationNeighborInetAddr != null && stationState.stationBoundDatagramSocket == null) { - logger(Log.WARN, "$logPrefix : sendOriginatingMessagesRunnable : could not send " + - "originating message to group owner socket not set on state") } - + //TODO: check on each interface if there are any known neighbors for which we do not have + //current originator messages } private val pingNeighborsRunnable = Runnable { + val neighbors = neighbors() neighbors.forEach { val neighborVirtualAddr = it.first val lastOrigininatorMessage = it.second val pingMessage = MmcpPing(messageId = nextMmcpMessageId()) - pendingPings.add(PendingPing(pingMessage, neighborVirtualAddr, System.currentTimeMillis())) + pendingPings.add( + PendingPing( + pingMessage, + neighborVirtualAddr, + System.currentTimeMillis() + ) + ) logger( priority = Log.VERBOSE, message = { "$logPrefix pingNeighborsRunnable: send ping to ${neighborVirtualAddr.addressToDotNotation()}" } ) - it.second.receivedFromSocket.send( - nextHopAddress = lastOrigininatorMessage.lastHopRealInetAddr, - nextHopPort = lastOrigininatorMessage.lastHopRealPort, - virtualPacket = pingMessage.toVirtualPacket( - toAddr = neighborVirtualAddr, - fromAddr = localNodeAddress, - lastHopAddr = localNodeAddress, - hopCount = 1, - ) - ) + val networkInterfaces = virtualNetworkInterfaces() + val addresses = networkInterfaces.map { it.virtualAddress } + + networkInterfaces.forEach { virtualNetworkInterface -> + + val networkInterfaceAddress = virtualNetworkInterface.virtualAddress.requireAddressAsInt() + + addresses.forEach { address-> + it.second.receivedFromSocket.send( + nextHopAddress = lastOrigininatorMessage.lastHopRealInetAddr, + nextHopPort = lastOrigininatorMessage.lastHopRealPort, + virtualPacket = pingMessage.toVirtualPacket( + toAddr = neighborVirtualAddr, + fromAddr = networkInterfaceAddress, + lastHopAddr = networkInterfaceAddress, + hopCount = 1, + ) + ) + + } + } + + } //Remove expired pings @@ -182,8 +206,10 @@ class OriginatingMessageManager( } nodesLost.forEach { - logger(Log.DEBUG, {"$logPrefix : checkLostNodesRunnable: " + - "Lost ${it.key.addressToDotNotation()} - no contact for ${timeNow - it.value.timeReceived}ms"}) + logger(Log.DEBUG, { + "$logPrefix : checkLostNodesRunnable: " + + "Lost ${it.key.addressToDotNotation()} - no contact for ${timeNow - it.value.timeReceived}ms" + }) originatorMessages.remove(it.key) } @@ -199,7 +225,10 @@ class OriginatingMessageManager( ) private val checkLostNodesFuture = scheduledExecutorService.scheduleAtFixedRate( - checkLostNodesRunnable, lostNodeCheckInterval.toLong(), lostNodeCheckInterval.toLong(), TimeUnit.MILLISECONDS + checkLostNodesRunnable, + lostNodeCheckInterval.toLong(), + lostNodeCheckInterval.toLong(), + TimeUnit.MILLISECONDS ) @Volatile @@ -217,7 +246,7 @@ class OriginatingMessageManager( private fun assertNotClosed() { - if(closed) + if (closed) throw IllegalStateException("$logPrefix is closed!") } @@ -232,7 +261,7 @@ class OriginatingMessageManager( //Dont keep originator messages in our own table for this node logger( Log.VERBOSE, - message= { + message = { "$logPrefix received originating message from " + "${virtualPacket.header.fromAddr.addressToDotNotation()} via " + virtualPacket.header.lastHopAddr.addressToDotNotation() @@ -270,7 +299,7 @@ class OriginatingMessageManager( } ) - if(currentOriginatorMessage == null || isMoreRecentOrBetter) { + if (currentOriginatorMessage == null || isMoreRecentOrBetter) { originatorMessages[virtualPacket.header.fromAddr] = VirtualNode.LastOriginatorMessage( originatorMessage = mmcpMessage.copyWithPingTimeIncrement(connectionPingTime), timeReceived = System.currentTimeMillis(), @@ -291,7 +320,7 @@ class OriginatingMessageManager( _state.value = originatorMessages.toMap() } - if(isNewNeighbor) { + if (isNewNeighbor) { //trigger immediate sending of originator messages so it can see us scheduledExecutorService.submit(sendOriginatingMessageRunnable) } @@ -303,15 +332,17 @@ class OriginatingMessageManager( fromVirtualAddr: Int, pong: MmcpPong, ) { - val pendingPingPredicate : (PendingPing) -> Boolean = { + val pendingPingPredicate: (PendingPing) -> Boolean = { it.ping.messageId == pong.replyToMessageId && it.toVirtualAddr == fromVirtualAddr } val pendingPing = pendingPings.firstOrNull(pendingPingPredicate) - if(pendingPing == null){ - logger(Log.WARN, "$logPrefix : onPongReceived : pong from " + - "${fromVirtualAddr.addressToDotNotation()} does not match any known sent ping") + if (pendingPing == null) { + logger( + Log.WARN, "$logPrefix : onPongReceived : pong from " + + "${fromVirtualAddr.addressToDotNotation()} does not match any known sent ping" + ) return } @@ -320,8 +351,10 @@ class OriginatingMessageManager( //Sometimes unit tests will run very quickly, and test may fail if ping time is 0 val pingTime = maxOf((timeNow - pendingPing.timesent).toShort(), 1) logger( - Log.VERBOSE, {"$logPrefix received ping from ${fromVirtualAddr.addressToDotNotation()} " + - "pingTime=$pingTime"} + Log.VERBOSE, { + "$logPrefix received ping from ${fromVirtualAddr.addressToDotNotation()} " + + "pingTime=$pingTime" + } ) neighborPingTimes[fromVirtualAddr] = PingTime( @@ -339,27 +372,33 @@ class OriginatingMessageManager( fun lookupNextHopForChainSocket(address: InetAddress, port: Int): ChainSocketNextHop { + + val addressInt = address.requireAddressAsInt() val originatorMessage = originatorMessages[addressInt] return when { //Destination address is this node - addressInt == localNodeAddress -> { + virtualNetworkInterfaces().any { it.virtualAddress.requireAddressAsInt() == addressInt } -> { ChainSocketNextHop(InetAddress.getLoopbackAddress(), port, true, null) } //Destination is a direct neighbor (final destination) - connect to the actual socket itself originatorMessage != null && originatorMessage.hopCount == 1.toByte() -> { - ChainSocketNextHop(originatorMessage.lastHopRealInetAddr, port, true, - originatorMessage.receivedFromSocket.boundNetwork) + ChainSocketNextHop( + originatorMessage.lastHopRealInetAddr, port, true, + originatorMessage.receivedFromSocket.boundNetwork + ) } //Destination is not a direct neighbor, but we have a route there originatorMessage != null -> { - ChainSocketNextHop(originatorMessage.lastHopRealInetAddr, + ChainSocketNextHop( + originatorMessage.lastHopRealInetAddr, originatorMessage.lastHopRealPort, false, - originatorMessage.receivedFromSocket.boundNetwork) + originatorMessage.receivedFromSocket.boundNetwork + ) } //No route available to reach the given address @@ -395,25 +434,32 @@ class OriginatingMessageManager( ) { logger(Log.DEBUG, "$logPrefix: addNeighbor - sending originating messages out") - //send originating packets out to the other device until we get something back from it val sendOriginatingMessageJob = scope.launch { - try { - val originatingMessage = makeOriginatingMessage() - socket.send( - nextHopAddress = neighborRealInetAddr, - nextHopPort = neighborRealPort, - virtualPacket = originatingMessage.toVirtualPacket( - toAddr = ADDR_BROADCAST, - fromAddr = localNodeAddress, - lastHopAddr = localNodeAddress, - hopCount = 1, + while (isActive) { + try { + val originatingMessage = makeOriginatingMessage() + virtualNetworkInterfaces().forEach { iface -> + val localAddress = iface.virtualAddress + socket.send( + nextHopAddress = neighborRealInetAddr, + nextHopPort = neighborRealPort, + virtualPacket = originatingMessage.toVirtualPacket( + toAddr = ADDR_BROADCAST, + fromAddr = localAddress.requireAddressAsInt(), + lastHopAddr = localAddress.requireAddressAsInt(), + hopCount = 1, + ) + ) + } + } catch (e: Exception) { + logger( + Log.WARN, + "$logPrefix : addNeighbor : exception trying to send originating message", + e ) - ) - }catch(e: Exception) { - logger(Log.WARN, "$logPrefix : addNeighbor : exception trying to send originating message", e) + } + delay(sendInterval.toLong()) } - - delay(sendInterval.toLong()) } try { @@ -421,23 +467,47 @@ class OriginatingMessageManager( val replyMessage = receivedMessages.filter { it.lastHopRealInetAddr == neighborRealInetAddr && it.lastHopRealPort == neighborRealPort }.first() - logger(Log.DEBUG, "$logPrefix addNeighbor - received originating message reply " + - "from ${replyMessage.lastHopAddr.addressToDotNotation()}") + logger( + Log.DEBUG, "$logPrefix addNeighbor - received originating message reply " + + "from ${replyMessage.lastHopAddr.addressToDotNotation()}" + ) } - }finally { + } finally { sendOriginatingMessageJob.cancel() } - } - fun neighbors() : List> { + fun neighbors(): List> { return originatorMessages.filter { it.value.hopCount == 1.toByte() }.map { it.key to it.value } } + fun selectOutgoingAddrForDestination(destination: InetAddress): InetAddress { + val destinationInt = destination.requireAddressAsInt() + + // Find the originator message with the shortest path to the destination + val bestRoute = originatorMessages[destinationInt] - fun close(){ + if (bestRoute == null) { + logger(Log.WARN, "$logPrefix No route found to destination $destination") + throw NoRouteToHostException("No route to host $destination") + } + + // Find the virtual network interface that matches the last hop address + val outgoingInterface = virtualNetworkInterfaces().find { iface -> + iface.virtualAddress.requireAddressAsInt() == bestRoute.lastHopAddr + } + + if (outgoingInterface == null) { + logger(Log.ERROR, "$logPrefix No matching interface found for route to $destination") + throw IllegalStateException("No matching interface for route to $destination") + } + + logger(Log.INFO, "$logPrefix Selected outgoing address ${outgoingInterface.virtualAddress} with ${bestRoute.hopCount} hops to reach $destination") + return outgoingInterface.virtualAddress + } + fun close() { sendOriginatorMessagesFuture.cancel(true) pingNeighborsFuture.cancel(true) checkLostNodesFuture.cancel(true) diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualNode.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualNode.kt index 88d4c6c..714f1d1 100644 --- a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualNode.kt +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualNode.kt @@ -21,6 +21,7 @@ import com.ustadmobile.meshrabiya.vnet.VirtualPacket.Companion.ADDR_BROADCAST import com.ustadmobile.meshrabiya.vnet.bluetooth.MeshrabiyaBluetoothState import com.ustadmobile.meshrabiya.vnet.datagram.VirtualDatagramSocket2 import com.ustadmobile.meshrabiya.vnet.datagram.VirtualDatagramSocketImpl +import com.ustadmobile.meshrabiya.vnet.netinterface.VirtualNetworkInterface import com.ustadmobile.meshrabiya.vnet.socket.ChainSocketFactory import com.ustadmobile.meshrabiya.vnet.socket.ChainSocketFactoryImpl import com.ustadmobile.meshrabiya.vnet.socket.ChainSocketNextHop @@ -86,7 +87,7 @@ abstract class VirtualNode( final override val address: InetAddress = randomApipaInetAddr(), override val networkPrefixLength: Int = 16, val config: NodeConfig = NodeConfig.DEFAULT_CONFIG, -): VirtualRouter, Closeable { +) : VirtualRouter, Closeable { val addressAsInt: Int = address.requireAddressAsInt() @@ -114,10 +115,16 @@ abstract class VirtualNode( private val forwardingRules: MutableMap = ConcurrentHashMap() + private val _virtualNetworkInterfaces = MutableStateFlow>( + emptyList() + ) + val virtualNetworkInterfaces: Flow> = + _virtualNetworkInterfaces.asStateFlow() + /** * @param originatorMessage the Originator message itself * @param timeReceived the time this message was received - * @param lastHopAddr the recorded last hop address + * @param lastHopAddr the recorded last hop address (Virtual address) */ data class LastOriginatorMessage( val originatorMessage: MmcpOriginatorMessage, @@ -127,7 +134,9 @@ abstract class VirtualNode( val lastHopRealInetAddr: InetAddress, val receivedFromSocket: VirtualNodeDatagramSocket, val lastHopRealPort: Int, - ) + val receivedFromInterface: VirtualNetworkInterface? = null, + + ) @Suppress("unused") //Part of the API enum class Zone { @@ -135,7 +144,9 @@ abstract class VirtualNode( } private val originatingMessageManager = OriginatingMessageManager( - localNodeInetAddr = address, + virtualNetworkInterfaces = { + _virtualNetworkInterfaces.value + }, logger = logger, scheduledExecutorService = scheduledExecutor, nextMmcpMessageId = this::nextMmcpMessageId, @@ -173,7 +184,8 @@ abstract class VirtualNode( onBufferOverflow = BufferOverflow.DROP_OLDEST ) - val incomingMmcpMessages: Flow = _incomingMmcpMessages.asSharedFlow() + val incomingMmcpMessages: Flow = + _incomingMmcpMessages.asSharedFlow() private val activeSockets: MutableMap = ConcurrentHashMap() @@ -196,6 +208,12 @@ abstract class VirtualNode( } } + fun addVirtualNetworkInterface(networkInterface: VirtualNetworkInterface){ + _virtualNetworkInterfaces.update { it-> + it + networkInterface + } + } + override fun nextMmcpMessageId() = mmcpMessageIdAtomic.incrementAndGet() @@ -203,8 +221,8 @@ abstract class VirtualNode( virtualDatagramSocketImpl: VirtualDatagramSocketImpl, portNum: Int ): Int { - if(portNum > 0) { - if(activeSockets.containsKey(portNum)) + if (portNum > 0) { + if (activeSockets.containsKey(portNum)) throw IllegalStateException("VirtualNode: port $portNum already allocated!") //requested port is not allocated, everything OK @@ -215,13 +233,13 @@ abstract class VirtualNode( var attemptCount = 0 do { val randomPort = Random.nextInt(0, Short.MAX_VALUE.toInt()) - if(!activeSockets.containsKey(randomPort)) { + if (!activeSockets.containsKey(randomPort)) { activeSockets[randomPort] = virtualDatagramSocketImpl return randomPort } attemptCount++ - }while(attemptCount < 100) + } while (attemptCount < 100) throw IllegalStateException("Could not allocate random free port") } @@ -248,12 +266,12 @@ abstract class VirtualNode( bindPort: Int, destAddress: InetAddress, destPort: Int, - ) : Int { - val listenSocket = if( + ): Int { + val listenSocket = if ( bindAddress.prefixMatches(networkPrefixLength, address) ) { createBoundDatagramSocket(bindPort) - }else { + } else { DatagramSocket(bindPort, bindAddress) } @@ -270,9 +288,9 @@ abstract class VirtualNode( destAddress: InetAddress, destPort: Int ): Int { - val listenSocket = if(bindZone == Zone.VNET) { + val listenSocket = if (bindZone == Zone.VNET) { createBoundDatagramSocket(bindPort) - }else { + } else { DatagramSocket(bindPort) } val forwardRule = createForwardRule(listenSocket, destAddress, destPort) @@ -299,7 +317,7 @@ abstract class VirtualNode( listenSocket: DatagramSocket, destAddress: InetAddress, destPort: Int, - ) : UdpForwardRule { + ): UdpForwardRule { return UdpForwardRule( boundSocket = listenSocket, ioExecutor = this.connectionExecutor, @@ -318,7 +336,7 @@ abstract class VirtualNode( protected fun generateConnectLink( hotspot: WifiConnectConfig?, bluetoothConfig: MeshrabiyaBluetoothState? = null, - ) : MeshrabiyaConnectLink { + ): MeshrabiyaConnectLink { return MeshrabiyaConnectLink.fromComponents( nodeAddr = addressAsInt, port = localDatagramPort, @@ -332,7 +350,7 @@ abstract class VirtualNode( virtualPacket: VirtualPacket, datagramPacket: DatagramPacket?, datagramSocket: VirtualNodeDatagramSocket?, - ) : Boolean { + ): Boolean { //This is an Mmcp message try { val mmcpMessage = MmcpMessage.fromVirtualPacket(virtualPacket) @@ -340,7 +358,7 @@ abstract class VirtualNode( logger(Log.VERBOSE, message = { "$logPrefix received MMCP message (${mmcpMessage::class.simpleName}) " + - "from ${from.addressToDotNotation()}" + "from ${from.addressToDotNotation()}" } ) @@ -366,12 +384,16 @@ abstract class VirtualNode( fromAddr = addressAsInt ) - logger(Log.VERBOSE, { "$logPrefix Sending pong to ${from.addressToDotNotation()}" }) + logger( + Log.VERBOSE, + { "$logPrefix Sending pong to ${from.addressToDotNotation()}" }) route(replyPacket) } mmcpMessage is MmcpPong && isToThisNode -> { - logger(Log.VERBOSE, { "$logPrefix Received pong(id=${mmcpMessage.messageId})}" }) + logger( + Log.VERBOSE, + { "$logPrefix Received pong(id=${mmcpMessage.messageId})}" }) originatingMessageManager.onPongReceived(from, mmcpMessage) pongListeners.forEach { it.onPongReceived(from, mmcpMessage) @@ -379,13 +401,17 @@ abstract class VirtualNode( } mmcpMessage is MmcpHotspotRequest && isToThisNode -> { - logger(Log.INFO, "$logPrefix Received hotspotrequest (id=${mmcpMessage.messageId})", null) + logger( + Log.INFO, + "$logPrefix Received hotspotrequest (id=${mmcpMessage.messageId})", + null + ) coroutineScope.launch { val hotspotResult = meshrabiyaWifiManager.requestHotspot( mmcpMessage.messageId, mmcpMessage.hotspotRequest ) - if(from != addressAsInt) { + if (from != addressAsInt) { val replyPacket = MmcpHotspotResponse( messageId = mmcpMessage.messageId, result = hotspotResult @@ -393,7 +419,11 @@ abstract class VirtualNode( toAddr = from, fromAddr = addressAsInt ) - logger(Log.INFO, "$logPrefix sending hotspotresponse to ${from.addressToDotNotation()}", null) + logger( + Log.INFO, + "$logPrefix sending hotspotresponse to ${from.addressToDotNotation()}", + null + ) route(replyPacket) } } @@ -413,10 +443,15 @@ abstract class VirtualNode( } } - _incomingMmcpMessages.tryEmit(MmcpMessageAndPacketHeader(mmcpMessage, virtualPacket.header)) + _incomingMmcpMessages.tryEmit( + MmcpMessageAndPacketHeader( + mmcpMessage, + virtualPacket.header + ) + ) return shouldRoute - }catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() return false } @@ -432,36 +467,41 @@ abstract class VirtualNode( try { val fromLastHop = packet.header.lastHopAddr - if(packet.header.hopCount >= config.maxHops) { - logger(Log.DEBUG, + if (packet.header.hopCount >= config.maxHops) { + logger( + Log.DEBUG, "Drop packet from ${packet.header.fromAddr.addressToDotNotation()} - " + "${packet.header.hopCount} exceeds ${config.maxHops}", - null) + null + ) return } - if(packet.header.toPort == 0 && packet.header.fromAddr != addressAsInt){ + if (packet.header.toPort == 0 && packet.header.fromAddr != addressAsInt) { //this is an MMCP message - if(!onIncomingMmcpMessage(packet, datagramPacket, virtualNodeDatagramSocket)){ + if (!onIncomingMmcpMessage(packet, datagramPacket, virtualNodeDatagramSocket)) { //It was determined that this packet should go no further by MMCP processing logger(Log.DEBUG, "Drop mmcp packet from ${packet.header.fromAddr}", null) } } - if(packet.header.toAddr == addressAsInt) { + if (packet.header.toAddr == addressAsInt) { //this is an incoming packet - give to the destination virtual socket/forwarding val listeningSocket = activeSockets[packet.header.toPort] - if(listeningSocket != null) { + if (listeningSocket != null) { listeningSocket.onIncomingPacket(packet) - }else { - logger(Log.DEBUG, "$logPrefix Incoming packet received, but no socket listening on: ${packet.header.toPort}") + } else { + logger( + Log.DEBUG, + "$logPrefix Incoming packet received, but no socket listening on: ${packet.header.toPort}" + ) } - }else { + } else { //packet needs to be sent to next hop / destination val toAddr = packet.header.toAddr packet.updateLastHopAddrAndIncrementHopCountInData(addressAsInt) - if(toAddr == ADDR_BROADCAST) { + if (toAddr == ADDR_BROADCAST) { originatingMessageManager.neighbors().filter { it.first != fromLastHop && it.first != packet.header.fromAddr }.forEach { @@ -481,23 +521,26 @@ abstract class VirtualNode( ) } - }else { + } else { val originatorMessage = originatingMessageManager .findOriginatingMessageFor(packet.header.toAddr) - if(originatorMessage != null) { + if (originatorMessage != null) { originatorMessage.receivedFromSocket.send( nextHopAddress = originatorMessage.lastHopRealInetAddr, nextHopPort = originatorMessage.lastHopRealPort, virtualPacket = packet ) - }else { - logger(Log.WARN, "$logPrefix route: Cannot route packet to " + - "${packet.header.toAddr.addressToDotNotation()} : no known nexthop") + } else { + logger( + Log.WARN, "$logPrefix route: Cannot route packet to " + + "${packet.header.toAddr.addressToDotNotation()} : no known nexthop" + ) } } } - }catch(e: Exception) { - logger(Log.ERROR, + } catch (e: Exception) { + logger( + Log.ERROR, "$logPrefix : route : exception routing packet from ${packet.header.fromAddr.addressToDotNotation()}", e ) @@ -519,7 +562,8 @@ abstract class VirtualNode( neighborNodeVirtualAddr: Int, socket: VirtualNodeDatagramSocket, ) { - logger(Log.DEBUG, + logger( + Log.DEBUG, "$logPrefix addNewNeighborConnection connection to virtual addr " + "${neighborNodeVirtualAddr.addressToDotNotation()} " + "via datagram to $address:$port", @@ -530,7 +574,7 @@ abstract class VirtualNode( originatingMessageManager.addNeighbor( neighborRealInetAddr = address, neighborRealPort = port, - socket = socket, + socket = socket, ) } @@ -549,15 +593,15 @@ abstract class VirtualNode( preferredBand: ConnectBand = ConnectBand.BAND_2GHZ, hotspotType: HotspotType = HotspotType.AUTO, ): LocalHotspotResponse? { - return if(enabled){ - meshrabiyaWifiManager.requestHotspot( + return if (enabled) { + meshrabiyaWifiManager.requestHotspot( requestMessageId = nextMmcpMessageId(), request = LocalHotspotRequest( preferredBand = preferredBand, preferredType = hotspotType, ) ) - }else { + } else { meshrabiyaWifiManager.deactivateHotspot() LocalHotspotResponse( responseToMessageId = 0, diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualPacket.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualPacket.kt index 28ff10f..4711832 100644 --- a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualPacket.kt +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/VirtualPacket.kt @@ -25,6 +25,7 @@ class VirtualPacket private constructor( assertHeaderAlreadyInData: Boolean = false, ) { + val header: VirtualPacketHeader init { @@ -73,7 +74,7 @@ class VirtualPacket private constructor( * * @param lastHopAddr the value to set for the last hop address */ - internal fun updateLastHopAddrAndIncrementHopCountInData( + fun updateLastHopAddrAndIncrementHopCountInData( lastHopAddr: Int ) { val byteBuffer = ByteBuffer.wrap(data, dataOffset + LAST_HOP_ADDR_OFFSET, 5) diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VServerSocket.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VServerSocket.kt new file mode 100644 index 0000000..a323230 --- /dev/null +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VServerSocket.kt @@ -0,0 +1,7 @@ +package com.ustadmobile.meshrabiya.vnet.netinterface + +interface VServerSocket { + + fun accept(): VSocket + +} \ No newline at end of file diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VSocket.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VSocket.kt new file mode 100644 index 0000000..98eaac7 --- /dev/null +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VSocket.kt @@ -0,0 +1,13 @@ +package com.ustadmobile.meshrabiya.vnet.netinterface + +import java.io.InputStream +import java.io.OutputStream + +/** + * Simplified Socket + */ +interface VSocket { + fun inputStream(): InputStream + fun outputStream(): OutputStream + fun close() +} \ No newline at end of file diff --git a/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VirtualNetworkInterface.kt b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VirtualNetworkInterface.kt new file mode 100644 index 0000000..0fbe786 --- /dev/null +++ b/lib-meshrabiya/src/main/java/com/ustadmobile/meshrabiya/vnet/netinterface/VirtualNetworkInterface.kt @@ -0,0 +1,73 @@ +package com.ustadmobile.meshrabiya.vnet.netinterface + +import com.ustadmobile.meshrabiya.log.MNetLogger +import com.ustadmobile.meshrabiya.vnet.VirtualPacket +import java.io.Closeable +import java.net.InetAddress + +/** + * Represents a Virtual Network Interface which can be implemented by different underlying transport + * layers. + * + * The Virtual Network Interface is responsible to maintain its own mapping of virtual addresses (as + * per the VirtualPacket.header) to a real address it can use with its own transport layer. This + * could be: + * - If using a TCP/IP link e.g. local WiFi: the real InetAddress and datagram port of a + * VirtualNodeDatagramSocket + * - If using Google Nearby, the device address + * - If using Bluetooth directly, the Bluetooth Mac address of the remote device. + * + * When a VirtualPacket is received by the underlying transport layer, The VirtualNetworkInterface + * should + * - update its mapping of virtual address to real transport layer addresses + * - call the VirtualNode.route(packet) function + * + * A VirtualPacket can be received by the underlying transport when: + * - If using a TCP/IP link, using a real DatagramSocket e.g. VirtualNodeDatagramSocket + * - If using Google Nearby, using the on incoming data callback + * - If using Bluetooth directly, using the GATT callback + * + * When an incoming stream is received by the underlying transport layer, the VirtualNetworkInterface + * should call VirtualNode.route(socket). + * + */ +interface VirtualNetworkInterface : Closeable { + + val virtualAddress: InetAddress + val logger: MNetLogger + /** + * Send the given VirtualPacket over this VirtualNetworkInterface to the NextHopAddress + * + * @param nextHopAddress the Virtual Address of the next hop to which the packet should be sent. + * @param virtualPacket the VirtualPacket to send. The final destination may (or may not) be the + * nextHopAddress. + */ + fun send( + virtualPacket: VirtualPacket, + nextHopAddress: InetAddress, + ) + + /** + * Connect a socket using this VirtualNetworkInterface using the underlying transport. This + * can be implemented as follows: + * + * - On TCP/IP networks: connect to the real address of the other node's ChainSocketFactory + * - On Google Nearby: + * Send a stream payload to the other node, using a PipedInputStream, where the PipedInputStream's + * input comes from VSocket.outputStream. + * Wait (e.g. via completablefuture) for the other node to send a Payload, the InputStream + * from the Payload is VSocket.inputStream + * Return the VSocket + * + * @param nextHopAddress the virtual Address of the next hop to which the stream will be + * connected, this may or may not be the destination address + * @param destAddress the final destination address of the stream + * @param destPort the port on the final destination address + */ + fun connectSocket( + nextHopAddress: InetAddress, + destAddress: InetAddress, + destPort: Int, + ): VSocket + +} \ No newline at end of file diff --git a/lib-nearby/.gitignore b/lib-nearby/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/lib-nearby/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lib-nearby/build.gradle.kts b/lib-nearby/build.gradle.kts new file mode 100644 index 0000000..a119567 --- /dev/null +++ b/lib-nearby/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.meshrabiya.lib_nearby" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + +} + +dependencies { + implementation(project(":lib-meshrabiya")) + implementation(libs.google.play.services.nearby) + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.google.material) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation (libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.espresso.core) + + + +} \ No newline at end of file diff --git a/lib-nearby/consumer-rules.pro b/lib-nearby/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/lib-nearby/proguard-rules.pro b/lib-nearby/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/lib-nearby/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/lib-nearby/src/androidTest/java/com/meshrabiya/lib_nearby/ExampleInstrumentedTest.kt b/lib-nearby/src/androidTest/java/com/meshrabiya/lib_nearby/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..7af0831 --- /dev/null +++ b/lib-nearby/src/androidTest/java/com/meshrabiya/lib_nearby/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.meshrabiya.lib_nearby + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.meshrabiya.lib_nearby.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/lib-nearby/src/main/AndroidManifest.xml b/lib-nearby/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/lib-nearby/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/MmcpMessage.kt b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/MmcpMessage.kt new file mode 100644 index 0000000..c3d9356 --- /dev/null +++ b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/MmcpMessage.kt @@ -0,0 +1,24 @@ +package com.meshrabiya.lib_nearby.nearby + +import java.nio.ByteBuffer + +abstract class MmcpMessage { + abstract val messageType: Int + abstract val fromAddress: Int + abstract val toAddress: Int + + abstract fun toBytes(): ByteArray + + companion object { + const val HEADER_SIZE = 12 + + fun fromBytes(bytes: ByteArray): MmcpMessage { + val buffer = ByteBuffer.wrap(bytes) + val messageType = buffer.int + return when (messageType) { + NearbyStreamHeader.MESSAGE_TYPE -> NearbyStreamHeader.fromBytes(bytes) + else -> throw IllegalArgumentException("Unknown message type: $messageType") + } + } + } +} \ No newline at end of file diff --git a/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyStreamHeader.kt b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyStreamHeader.kt new file mode 100644 index 0000000..ce25ab5 --- /dev/null +++ b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyStreamHeader.kt @@ -0,0 +1,44 @@ +package com.meshrabiya.lib_nearby.nearby + +import java.nio.ByteBuffer + +class NearbyStreamHeader( + val streamId: Int, + val isReply: Boolean, + val payloadSize: Int, + override val fromAddress: Int, + override val toAddress: Int +) : MmcpMessage() { + + override val messageType: Int = MESSAGE_TYPE + + override fun toBytes(): ByteArray { + return ByteBuffer.allocate(HEADER_SIZE).apply { + putInt(messageType) + putInt(fromAddress) + putInt(toAddress) + putInt(streamId) + put(isReply.toByte()) + putInt(payloadSize) + }.array() + } + + companion object { + const val MESSAGE_TYPE = 1 + const val HEADER_SIZE = MmcpMessage.HEADER_SIZE + 13 + + fun fromBytes(bytes: ByteArray): NearbyStreamHeader { + val buffer = ByteBuffer.wrap(bytes) + buffer.position(MmcpMessage.HEADER_SIZE) + return NearbyStreamHeader( + streamId = buffer.int, + isReply = buffer.get() != 0.toByte(), + payloadSize = buffer.int, + fromAddress = ByteBuffer.wrap(bytes).getInt(4), + toAddress = ByteBuffer.wrap(bytes).getInt(8) + ) + } + } +} + +fun Boolean.toByte(): Byte = if (this) 1 else 0 \ No newline at end of file diff --git a/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyVirtualNetwork.kt b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyVirtualNetwork.kt new file mode 100644 index 0000000..055ae76 --- /dev/null +++ b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/NearbyVirtualNetwork.kt @@ -0,0 +1,569 @@ +package com.meshrabiya.lib_nearby.nearby + + +import android.content.Context +import android.util.Log +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.nearby.Nearby +import com.google.android.gms.nearby.connection.AdvertisingOptions +import com.google.android.gms.nearby.connection.ConnectionInfo +import com.google.android.gms.nearby.connection.ConnectionLifecycleCallback +import com.google.android.gms.nearby.connection.ConnectionResolution +import com.google.android.gms.nearby.connection.DiscoveredEndpointInfo +import com.google.android.gms.nearby.connection.DiscoveryOptions +import com.google.android.gms.nearby.connection.EndpointDiscoveryCallback +import com.google.android.gms.nearby.connection.Payload +import com.google.android.gms.nearby.connection.PayloadCallback +import com.google.android.gms.nearby.connection.PayloadTransferUpdate +import com.google.android.gms.nearby.connection.Strategy +import com.ustadmobile.meshrabiya.ext.addressToByteArray +import com.ustadmobile.meshrabiya.ext.addressToDotNotation +import com.ustadmobile.meshrabiya.ext.asInetAddress +import com.ustadmobile.meshrabiya.log.MNetLogger +import com.ustadmobile.meshrabiya.mmcp.MmcpPing +import com.ustadmobile.meshrabiya.mmcp.MmcpPong +import com.ustadmobile.meshrabiya.vnet.VirtualPacket +import com.ustadmobile.meshrabiya.vnet.netinterface.VSocket +import com.ustadmobile.meshrabiya.vnet.netinterface.VirtualNetworkInterface +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.InputStream +import java.net.InetAddress +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.random.Random + + +class NearbyVirtualNetwork( + context: Context, + private val name: String, + private val serviceId: String, + private val virtualIpAddress: Int, + private val broadcastAddress: Int, + private val strategy: Strategy = Strategy.P2P_CLUSTER, + override val logger: MNetLogger, + private val onPacketReceived: (VirtualPacket) -> Unit +) : VirtualNetworkInterface { + + override val virtualAddress: InetAddress get() = InetAddress.getByAddress(virtualIpAddress.addressToByteArray()) + + private val streamReplies = ConcurrentHashMap>() + + private val endpointIpMap = ConcurrentHashMap() + + private val connectionsClient = Nearby.getConnectionsClient(context) + private val desiredOutgoingConnections = 3 + private val scope = CoroutineScope(Dispatchers.Default) + private val isClosed = AtomicBoolean(false) + + data class EndpointInfo( + val endpointId: String, + val status: EndpointStatus, + val ipAddress: InetAddress?, + val isOutgoing: Boolean + ) + + enum class EndpointStatus { + DISCONNECTED, CONNECTING, CONNECTED, DISCONNECTING + } + + enum class LogLevel { + VERBOSE, DEBUG, INFO, WARNING, ERROR + } + + private val _endpointStatusFlow = MutableStateFlow>(mutableMapOf()) + val endpointStatusFlow = _endpointStatusFlow.asStateFlow() + + /** + * Connection lifecycle manager callback. + * Handles the complete connection lifecycle: + * - Connection initiation + * - Connection establishment + * - Connection termination + * Updates endpoint status flow to reflect current connection states. + */ + private val connectionLifecycleCallback = object : ConnectionLifecycleCallback() { + override fun onConnectionInitiated(endpointId: String, connectionInfo: ConnectionInfo) { + checkClosed() + + // Check current state first + val currentState = _endpointStatusFlow.value[endpointId]?.status + if (currentState == EndpointStatus.CONNECTED) { + log(LogLevel.INFO, "Already connected to endpoint: $endpointId") + return + } + + val endpointIp = try { + val parts = connectionInfo.endpointName.split("|") + if (parts.size > 1) InetAddress.getByName(parts[1]) else null + } catch (e: Exception) { + log(LogLevel.ERROR, "Failed to parse IP from endpoint name", e) + null + } + + log(LogLevel.INFO, "Connection initiated with endpoint: $endpointId (IP: ${endpointIp?.hostAddress})") + connectionsClient.acceptConnection(endpointId, payloadCallback) + + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + val existingInfo = this[endpointId] + this[endpointId] = existingInfo?.copy( + status = EndpointStatus.CONNECTING, + ipAddress = endpointIp ?: existingInfo.ipAddress + ) ?: EndpointInfo( + endpointId = endpointId, + status = EndpointStatus.CONNECTING, + ipAddress = endpointIp, + isOutgoing = false + ) + } + } + } + + override fun onConnectionResult(endpointId: String, result: ConnectionResolution) { + checkClosed() + if (result.status.isSuccess) { + log(LogLevel.INFO, "Connection successful with endpoint: $endpointId") + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + val current = this[endpointId] + if (current != null) { + this[endpointId] = current.copy( + status = EndpointStatus.CONNECTED, + ipAddress = current.ipAddress // Preserve IP if exists + ) + } + } + } + // Send ping immediately after successful connection This will help establish IP mapping on both sides + sendMmcpPingPacket(endpointId) + } else { + log(LogLevel.ERROR, "Connection failed with endpoint: $endpointId. Status: ${result.status}") + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + remove(endpointId) + } + } + } + } + + override fun onDisconnected(endpointId: String) { + checkClosed() + log(LogLevel.INFO, "Disconnected from endpoint: $endpointId") + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + remove(endpointId) + } + } + log(LogLevel.DEBUG, "Current endpoints after disconnect: ${_endpointStatusFlow.value}") + } + } + + init { + + } + + fun start() { + startAdvertising() + startDiscovery() + observeEndpointStatusFlow() + } + + override fun close() { + try { + connectionsClient.stopAdvertising() + connectionsClient.stopDiscovery() + connectionsClient.stopAllEndpoints() + _endpointStatusFlow.value = emptyMap() + scope.cancel() + streamReplies.forEach { (_, future) -> future.cancel(true) } + streamReplies.clear() + endpointIpMap.clear() + log(LogLevel.INFO, "Network is closed and all operations have been stopped.") + } catch (e: Exception) { + log(LogLevel.ERROR, "Error during network closure", e) + } + } + + /** + * Broadcasts device presence to nearby peers using Nearby Connections API. + * Includes device identification (name and virtual IP) in advertising payload. + * + * Throws IllegalStateException if network is closed. + */ + private fun startAdvertising() { + checkClosed() + // Send actual virtual IP address, not broadcast + val deviceNameWithIP = "$name|${virtualAddress.hostAddress}" + val advertisingOptions = AdvertisingOptions.Builder().setStrategy(strategy).build() + + log(LogLevel.DEBUG, "Starting advertising with device IP: ${virtualAddress.hostAddress} callback =$connectionLifecycleCallback") + + connectionsClient.startAdvertising( + deviceNameWithIP, serviceId, connectionLifecycleCallback, advertisingOptions + ).addOnSuccessListener { + log(LogLevel.INFO, "Started advertising with name: $deviceNameWithIP") + }.addOnFailureListener { e -> + log(LogLevel.ERROR, "Failed to start advertising", e) + } + } + + /** + * Initiates discovery of nearby peers advertising the same service. + * Uses configured Strategy (P2P_CLUSTER by default) for connection type. + * + * Throws IllegalStateException if network is closed. + */ + private fun startDiscovery() { + checkClosed() + val discoveryOptions = DiscoveryOptions.Builder().setStrategy(strategy).build() + log(LogLevel.INFO, "request start discovery: serviceId = $serviceId") + connectionsClient.startDiscovery( + serviceId, endpointDiscoveryCallback, discoveryOptions + ).addOnSuccessListener { + log(LogLevel.INFO, "Started discovery successfully") + }.addOnFailureListener { e -> + log(LogLevel.ERROR, "Failed to start discovery", e) + } + } + + /** + * Routes virtual packets to appropriate endpoints. + * Handles both broadcast and unicast transmission modes. + * + * @param virtualPacket The packet to send + * @param nextHopAddress Target address (broadcast or specific endpoint) + * @throws IllegalArgumentException if target endpoint not found for unicast + */ + override fun send(virtualPacket: VirtualPacket, nextHopAddress: InetAddress) { + val connectedEndpoints = _endpointStatusFlow.value.filter { it.value.status == EndpointStatus.CONNECTED } + + //If the nextHopAddress is the broadcast address, send to all known points. + //Else, send only to the related endpoint; if not found, throw exception. + if (nextHopAddress.address.contentEquals(InetAddress.getByAddress(broadcastAddress.addressToByteArray()).address)) { + log(LogLevel.INFO, "Broadcasting packet to all connected endpoints") + connectedEndpoints.forEach { (endpointId, _) -> + sendPacketToEndpoint(endpointId, virtualPacket) + } + } + else { + val matchingEndpoint = connectedEndpoints.entries.find { (_, info) -> + info.ipAddress?.address?.contentEquals(nextHopAddress.address) == true + } + + if (matchingEndpoint != null) { + log(LogLevel.INFO, "Sending packet to specific endpoint: ${matchingEndpoint.key}") + sendPacketToEndpoint(matchingEndpoint.key, virtualPacket) + } else { + throw IllegalArgumentException("No connected endpoint found for IP address: $nextHopAddress") + } + } + } + + /** + * Transmits a virtual packet to a specific endpoint. + * Converts packet to Nearby Connections payload format. + * + * @param endpointId Identifier of target endpoint + * @param virtualPacket Packet data to transmit + */ + private fun sendPacketToEndpoint(endpointId: String, virtualPacket: VirtualPacket) { + val payload = Payload.fromBytes(virtualPacket.data) + connectionsClient.sendPayload(endpointId, payload) + .addOnSuccessListener { log(LogLevel.INFO, "Virtual packet sent to $endpointId") } + .addOnFailureListener { e -> log(LogLevel.ERROR, "Failed to send virtual packet to $endpointId", e) } + } + + override fun connectSocket(nextHopAddress: InetAddress, destAddress: InetAddress, destPort: Int): VSocket { + log(LogLevel.INFO, "Connecting to socket: $nextHopAddress -> $destAddress:$destPort") + return TODO("Provide the return value") + } + + + + /** + * Endpoint discovery callback processor. + * Manages endpoint discovery events: + * - Processing new endpoint detection + * - Handling endpoint loss/disconnection + * - Maintaining endpoint status mapping + */ + private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback() { + override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) { + checkClosed() + + // Check if already connected + val currentState = _endpointStatusFlow.value[endpointId]?.status + if (currentState == EndpointStatus.CONNECTED || currentState == EndpointStatus.CONNECTING) { + log(LogLevel.INFO, "Endpoint $endpointId already connected/connecting") + return + } + + val endpointIp = try { + val parts = info.endpointName.split("|") + if (parts.size > 1) InetAddress.getByName(parts[1]) else null + } catch (e: Exception) { + log(LogLevel.ERROR, "Failed to parse IP from endpoint name", e) + null + } + + log(LogLevel.DEBUG, "New endpoint found: $endpointId with Virtual IP: ${endpointIp?.hostAddress}") + + val updatedMap = ConcurrentHashMap(_endpointStatusFlow.value) + updatedMap[endpointId] = EndpointInfo( + endpointId = endpointId, + status = EndpointStatus.DISCONNECTED, + ipAddress = endpointIp, + isOutgoing = false + ) + _endpointStatusFlow.value = updatedMap + } + + override fun onEndpointLost(endpointId: String) { + checkClosed() + log(LogLevel.INFO, "Lost endpoint: $endpointId") + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + remove(endpointId) + } + } + } + } + + /** + * Payload data processor callback. + * Routes different payload types to appropriate handlers: + * - BYTES: Network packets and control messages + * - STREAM: Continuous data streams + * + * Maintains transfer status logging. + */ + val payloadCallback = object : PayloadCallback() { + override fun onPayloadReceived(endpointId: String, payload: Payload) { + checkClosed() + when (payload.type) { + Payload.Type.BYTES -> handleBytesPayload(endpointId, payload) + Payload.Type.STREAM -> handleStreamPayload(endpointId, payload) + else -> log(LogLevel.WARNING, "Received unsupported payload type from: $endpointId") + } + } + + override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) { + checkClosed() + log(LogLevel.DEBUG, "Payload transfer update for $endpointId: ${update.status}") + } + } + + /** + * Monitors endpoint status changes and manages connections. + * - Tracks connected endpoint count + * - Initiates new connections when below desired threshold + */ + private fun observeEndpointStatusFlow() { + scope.launch { + endpointStatusFlow.collect { endpointMap -> + val connectedEndpoints = endpointMap.values.count { + it.status == EndpointStatus.CONNECTED + } + + // Only proceed if we need more connections + if (connectedEndpoints < desiredOutgoingConnections) { + val disconnectedEndpoints = endpointMap.values.filter { + it.status == EndpointStatus.DISCONNECTED + } + + disconnectedEndpoints.forEach { endpoint -> + if (endpoint.status != EndpointStatus.CONNECTED && + endpoint.status != EndpointStatus.CONNECTING) { + log(LogLevel.INFO, "Initiating connection to: ${endpoint.endpointId}") + requestConnection(endpoint.endpointId) + } + } + } + } + } + } + + /** + * Initiates connection to a specific endpoint. + * Handles connection state management: + * - Prevents duplicate connection attempts + * - Updates endpoint status + * - Processes connection failures + * + * @param endpointId Target endpoint identifier + */ + private fun requestConnection(endpointId: String) { + checkClosed() + + // Get current endpoint state + val currentEndpoint = _endpointStatusFlow.value[endpointId] + + // Skip if already connected or connecting + if (currentEndpoint?.status == EndpointStatus.CONNECTED || + currentEndpoint?.status == EndpointStatus.CONNECTING) { + log(LogLevel.INFO, "Skip connection request - endpoint $endpointId status: ${currentEndpoint.status}") + return + } + + // Update status to CONNECTING + updateEndpointStatus(endpointId, EndpointStatus.CONNECTING) + + connectionsClient.requestConnection(name, endpointId, connectionLifecycleCallback) + .addOnSuccessListener { + log(LogLevel.INFO, "Connection request sent to endpoint: $endpointId") + } + .addOnFailureListener { e -> + when ((e as? ApiException)?.statusCode) { + 8003 -> { // Already connected + log(LogLevel.INFO, "Endpoint $endpointId is already connected") + updateEndpointStatus(endpointId, EndpointStatus.CONNECTED) + } + else -> { + log(LogLevel.ERROR, "Failed to request connection to endpoint: $endpointId", e) + updateEndpointStatus(endpointId, EndpointStatus.DISCONNECTED) + } + } + } + } + + /** + * Processes received byte payloads from endpoints. + * - Extracts virtual packet data + * - Updates endpoint IP mappings + * - Forwards to packet handler + * + * @param endpointId Source endpoint identifier + * @param payload Received byte payload + */ + private fun handleBytesPayload(endpointId: String, payload: Payload) { + checkClosed() + + val bytes = payload.asBytes() ?: run { + log(LogLevel.WARNING, "Received null payload from endpoint: $endpointId") + return + } + + val virtualPacket = try { + VirtualPacket.fromData(bytes, 0) + } catch (e: Exception) { + log(LogLevel.ERROR, "Failed to convert payload to VirtualPacket from endpoint: $endpointId", e) + return + } + + // Get sender's virtual IP (not broadcast address) + val fromAddr = virtualPacket.header.fromAddr?.takeIf { + !it.addressToDotNotation().equals("255.255.255.255") + } + + if (fromAddr != null) { + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + val existingInfo = this[endpointId] + this[endpointId] = existingInfo?.copy( + ipAddress = fromAddr.asInetAddress(), + status = existingInfo.status, + isOutgoing = existingInfo.isOutgoing + ) ?: EndpointInfo( + endpointId = endpointId, + status = EndpointStatus.CONNECTED, + ipAddress = fromAddr.asInetAddress(), + isOutgoing = false + ) + } + } + + // Also update IP map + endpointIpMap[endpointId] = fromAddr.asInetAddress() + log(LogLevel.DEBUG, "Updated IP mapping for endpoint $endpointId to ${fromAddr.addressToDotNotation()}") + } + + try { + onPacketReceived(virtualPacket) + } catch (e: Exception) { + log(LogLevel.ERROR, "Error processing VirtualPacket from $endpointId", e) + } + } + + /** + * Processes received stream payloads. + * Routes streams based on header information: + * - Reply streams to pending requests + * - New streams to stream handler + * + * @param endpointId Source endpoint identifier + * @param payload Stream payload data + */ + private fun handleStreamPayload(endpointId: String, payload: Payload) { + checkClosed() + payload.asStream()?.asInputStream()?.use { inputStream -> + val header = inputStream.readNearbyStreamHeader() + + if (header.isReply) { + streamReplies[header.streamId]?.complete(inputStream) + } else { + handleIncomingStream(endpointId, header, inputStream) + } + } + } + + private fun handleIncomingStream(endpointId: String, header: NearbyStreamHeader, inputStream: InputStream) { + log(LogLevel.INFO, "Received new stream from $endpointId with streamId: ${header.streamId}, fromAddress: ${header.fromAddress}, toAddress: ${header.toAddress}") + } + + private fun updateEndpointStatus(endpointId: String, status: EndpointStatus) { + _endpointStatusFlow.update { currentMap -> + currentMap.toMutableMap().apply { + this[endpointId] = this[endpointId]?.copy(status = status) + ?: EndpointInfo(endpointId, status, null, false) + } + } + } + + private fun sendMmcpPingPacket(endpointId: String) { + checkClosed() + // Use virtual IP instead of broadcast for ping + val mmcpPing = MmcpPing(Random.nextInt()) + val virtualPacket = mmcpPing.toVirtualPacket(virtualIpAddress, virtualIpAddress) // Changed here + val payload = Payload.fromBytes(virtualPacket.data) + + connectionsClient.sendPayload(endpointId, payload) + .addOnSuccessListener { log(LogLevel.DEBUG, "Sent MMCP Ping to $endpointId") } + .addOnFailureListener { e -> log(LogLevel.ERROR, "Failed to send MMCP Ping to $endpointId", e) } + } + + private fun sendMmcpPongPacket(endpointId: String, replyToMessageId: Int) { + checkClosed() + val mmcpPong = MmcpPong(Random.nextInt(), replyToMessageId) + val virtualPacket = mmcpPong.toVirtualPacket(virtualIpAddress, broadcastAddress) + val payload = Payload.fromBytes(virtualPacket.data) + + connectionsClient.sendPayload(endpointId, payload) + .addOnSuccessListener { log(LogLevel.DEBUG, "Sent MMCP Pong to $endpointId") } + .addOnFailureListener { e -> log(LogLevel.ERROR, "Failed to send MMCP Pong to $endpointId", e) } + } + + private fun log(level: LogLevel, message: String, exception: Exception? = null) { + val prefix = "[NearbyVirtualNetwork:$name] " + when (level) { + LogLevel.VERBOSE -> logger(Log.VERBOSE, "$prefix$message", exception) + LogLevel.DEBUG -> logger(Log.DEBUG, "$prefix$message", exception) + LogLevel.INFO -> logger(Log.INFO, "$prefix$message", exception) + LogLevel.WARNING -> logger(Log.WARN, "$prefix$message", exception) + LogLevel.ERROR -> logger(Log.ERROR, "$prefix$message", exception) + } + } + + private fun checkClosed() { + if (isClosed.get()) { + throw IllegalStateException("Network is closed") + } + } +} diff --git a/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/StreamExtensions.kt b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/StreamExtensions.kt new file mode 100644 index 0000000..9aebfed --- /dev/null +++ b/lib-nearby/src/main/java/com/meshrabiya/lib_nearby/nearby/StreamExtensions.kt @@ -0,0 +1,15 @@ +package com.meshrabiya.lib_nearby.nearby + +import java.io.InputStream +import java.io.OutputStream + + +fun OutputStream.writeNearbyStreamHeader(header: NearbyStreamHeader) { + write(header.toBytes()) +} + +fun InputStream.readNearbyStreamHeader(): NearbyStreamHeader { + val headerBytes = ByteArray(NearbyStreamHeader.HEADER_SIZE) + read(headerBytes) + return NearbyStreamHeader.fromBytes(headerBytes) +} \ No newline at end of file diff --git a/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/ExampleUnitTest.kt b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/ExampleUnitTest.kt new file mode 100644 index 0000000..ea947fa --- /dev/null +++ b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.meshrabiya.lib_nearby + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyStreamHeaderTest.kt b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyStreamHeaderTest.kt new file mode 100644 index 0000000..53702b3 --- /dev/null +++ b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyStreamHeaderTest.kt @@ -0,0 +1,51 @@ +package com.meshrabiya.lib_nearby + +import com.meshrabiya.lib_nearby.nearby.NearbyStreamHeader +import org.junit.Test +import org.junit.Assert.* +import java.nio.ByteBuffer + +class NearbyStreamHeaderTest { + + @Test + fun testNearbyStreamHeaderToBytes() { + val header = NearbyStreamHeader( + streamId = 1234, + isReply = true, + payloadSize = 5678, + fromAddress = 192168001, + toAddress = 192168002 + ) + + val bytes = header.toBytes() + val buffer = ByteBuffer.wrap(bytes) + + assertEquals(NearbyStreamHeader.MESSAGE_TYPE, buffer.int) + assertEquals(192168001, buffer.int) + assertEquals(192168002, buffer.int) + assertEquals(1234, buffer.int) + assertEquals(1.toByte(), buffer.get()) + assertEquals(5678, buffer.int) + } + + @Test + fun testNearbyStreamHeaderFromBytes() { + val originalHeader = NearbyStreamHeader( + streamId = 1234, + isReply = false, + payloadSize = 5678, + fromAddress = 192168001, + toAddress = 192168002 + ) + + val bytes = originalHeader.toBytes() + val reconstructedHeader = NearbyStreamHeader.fromBytes(bytes) + + assertEquals(originalHeader.messageType, reconstructedHeader.messageType) + assertEquals(originalHeader.fromAddress, reconstructedHeader.fromAddress) + assertEquals(originalHeader.toAddress, reconstructedHeader.toAddress) + assertEquals(originalHeader.streamId, reconstructedHeader.streamId) + assertEquals(originalHeader.isReply, reconstructedHeader.isReply) + assertEquals(originalHeader.payloadSize, reconstructedHeader.payloadSize) + } +} \ No newline at end of file diff --git a/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyVirtualNetworkTest.kt b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyVirtualNetworkTest.kt new file mode 100644 index 0000000..390c2e5 --- /dev/null +++ b/lib-nearby/src/test/java/com/meshrabiya/lib_nearby/NearbyVirtualNetworkTest.kt @@ -0,0 +1,100 @@ +package com.meshrabiya.lib_nearby + +import android.content.Context +import com.google.android.gms.nearby.Nearby +import com.google.android.gms.nearby.connection.ConnectionsClient +import com.google.android.gms.nearby.connection.Payload +import com.meshrabiya.lib_nearby.nearby.NearbyVirtualNetwork +import com.ustadmobile.meshrabiya.log.MNetLogger +import junit.framework.TestCase.assertNotNull +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations +import java.net.InetAddress + + +@ExperimentalCoroutinesApi +class NearbyVirtualNetworkTest { + + @Mock + private lateinit var mockContext: Context + + @Mock + private lateinit var mockConnectionsClient: ConnectionsClient + + @Mock + private lateinit var mockLogger: MNetLogger + + private lateinit var nearbyVirtualNetwork: NearbyVirtualNetwork + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + `when`(Nearby.getConnectionsClient(mockContext)).thenReturn(mockConnectionsClient) + + nearbyVirtualNetwork = NearbyVirtualNetwork( + context = mockContext, + name = "TestDevice", + serviceId = "TestService", + virtualIpAddress = InetAddress.getByName("192.168.0.1").hashCode(), + broadcastAddress = InetAddress.getByName("192.168.0.255").hashCode(), + logger = mockLogger + ) + } + + @Test + fun testStartNetwork() = runTest { + nearbyVirtualNetwork.start() + verify(mockConnectionsClient).startAdvertising( + eq("TestDevice"), + eq("TestService"), + any(), + any() + ) + verify(mockConnectionsClient).startDiscovery( + eq("TestService"), + any(), + any() + ) + } + + @Test + fun testSendMessage() { + val testEndpointId = "testEndpoint" + val testMessage = "Hello, World!" + nearbyVirtualNetwork.sendMessage(testEndpointId, testMessage) + verify(mockConnectionsClient).sendPayload(eq(testEndpointId), any()) + } + + @Test + fun testMessageReceivedListener() { + var receivedEndpointId: String? = null + var receivedPayload: Payload? = null + + nearbyVirtualNetwork.setOnMessageReceivedListener { endpointId, payload -> + receivedEndpointId = endpointId + receivedPayload = payload + } + + val testEndpointId = "testEndpoint" + val testPayload = Payload.fromBytes("Test Message".toByteArray()) + nearbyVirtualNetwork.payloadCallback.onPayloadReceived(testEndpointId, testPayload) + + assertEquals(testEndpointId, receivedEndpointId) + assertNotNull(receivedPayload) + } + + @Test + fun testCloseNetwork() { + nearbyVirtualNetwork.close() + verify(mockConnectionsClient).stopAdvertising() + verify(mockConnectionsClient).stopDiscovery() + verify(mockConnectionsClient).stopAllEndpoints() + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 78bd0a3..6023b99 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,5 +17,7 @@ dependencyResolutionManagement { } rootProject.name = "Meshrabiya" include ':lib-meshrabiya' +include ':lib-nearby' include ':test-app' include ':test-shared' +include ':lib-meshrabiya-vpn' diff --git a/test-app/build.gradle b/test-app/build.gradle index 481a89a..65cf81b 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.plugin.serialization' id "com.jaredsburrows.license" version "0.9.3" + } android { @@ -58,56 +59,60 @@ android { } dependencies { + implementation(project(":lib-meshrabiya")) + implementation(project(":lib-nearby")) + implementation(project(":lib-meshrabiya-vpn")) + + implementation(libs.google.play.services.nearby) - implementation project(':lib-meshrabiya') - implementation "ch.acra:acra-http:$version_acra" - implementation "ch.acra:acra-dialog:$version_acra" + implementation libs.acra.http + implementation libs.acra.dialog - implementation "com.github.yuriy-budiyev:code-scanner:$version_code_scanner" - implementation "androidx.navigation:navigation-compose:$version_navigation" + implementation libs.code.scanner + implementation libs.androidx.navigation.compose implementation "org.kodein.di:kodein-di-framework-compose:$version_kodein_di" - implementation "org.kodein.di:kodein-di-framework-android-x:$version_kodein_di" - implementation "androidx.datastore:datastore-preferences:$version_datastore" - implementation "com.squareup.okhttp3:okhttp:$version_okhttp" - implementation "org.nanohttpd:nanohttpd:$version_nanohttpd" - implementation "com.github.seancfoley:ipaddress:$version_ip_address" + implementation libs.kodein.di.framework.android.x + implementation libs.datastore + implementation libs.okhttp + implementation libs.nanohttpd + implementation libs.ipaddress implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$version_kotlinx_serialization" - implementation "com.journeyapps:zxing-android-embedded:$version_zxing_embedded" + implementation libs.zxing.android.embedded - implementation "androidx.core:core:$version_androidx_core" - implementation "androidx.core:core-ktx:$version_androidx_core" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$version_android_lifecycle" - implementation "androidx.activity:activity-compose:$version_android_activity" + implementation libs.core + implementation libs.androidx.core + implementation libs.androidx.lifecycle.runtime.ktx + implementation libs.androidx.activity.compose implementation platform("androidx.compose:compose-bom:$version_compose_bom") - implementation 'androidx.compose.ui:ui' - implementation 'androidx.compose.ui:ui-graphics' - implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material3:material3' - implementation "androidx.compose.material:material-icons-extended" + implementation libs.ui + implementation libs.ui.graphics + implementation libs.ui.tooling.preview + implementation libs.material3 + implementation libs.material.icons.extended - implementation "org.bouncycastle:bcprov-jdk18on:$version_bouncycastle" - implementation "org.bouncycastle:bcpkix-jdk18on:$version_bouncycastle" - implementation "com.google.accompanist:accompanist-webview:$version_compose_accompanist" + implementation libs.bouncycastle.prov + implementation libs.bouncycastle.pkix + implementation libs.accompanist.webview coreLibraryDesugaring "com.android.tools:desugar_jdk_libs_nio:$version_android_desugaring" - testImplementation "junit:junit:$version_junit" - androidTestImplementation "androidx.test.ext:junit:$version_android_test_ext_junit" + testImplementation libs.junit + androidTestImplementation libs.androidx.junit androidTestImplementation platform("androidx.compose:compose-bom:$version_compose_bom") - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + androidTestImplementation libs.ui.test.junit4 - androidTestImplementation "androidx.test:runner:$version_android_junit_runner" - androidTestImplementation "androidx.test:rules:$version_androidx_test_rules" + androidTestImplementation libs.android.junit.runner + androidTestImplementation libs.androidx.test.rules androidTestImplementation project(":test-shared") - androidTestImplementation "org.mockito:mockito-android:$version_android_mockito" - androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$version_kotlin_mockito" + androidTestImplementation libs.mockito.android + androidTestImplementation libs.mockito.kotlin androidTestImplementation "app.cash.turbine:turbine:$version_turbine" androidTestUtil "androidx.test:orchestrator:$version_androidx_orchestrator" - debugImplementation 'androidx.compose.ui:ui-tooling' - debugImplementation 'androidx.compose.ui:ui-test-manifest' + debugImplementation libs.ui.tooling + debugImplementation libs.ui.test.manifest } \ No newline at end of file diff --git a/test-app/src/main/AndroidManifest.xml b/test-app/src/main/AndroidManifest.xml index 949aa5e..a5e203e 100644 --- a/test-app/src/main/AndroidManifest.xml +++ b/test-app/src/main/AndroidManifest.xml @@ -2,42 +2,46 @@ - + + + + - + + + - + - - + + + + + + + + + - + + - - + + - - - - - @@ -55,6 +59,18 @@ android:supportsRtl="true" android:theme="@style/Theme.HttpOverBluetooth"> + + + + + + + () with singleton { + AddNearbyNetworkUseCase( + virtualNode = instance(), + context = applicationContext, + logger = instance(), + ) + } + bind() with singleton { val node: AndroidVirtualNode = instance() //Local connections, even when fast and with high throughput, can have high latency diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VNetTestActivity.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VNetTestActivity.kt index b7b486f..008741d 100644 --- a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VNetTestActivity.kt +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VNetTestActivity.kt @@ -1,12 +1,21 @@ package com.ustadmobile.meshrabiya.testapp +import android.Manifest +import android.content.Intent +import android.os.Build import android.os.Bundle +import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.ConnectWithoutContact import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Info @@ -25,61 +34,115 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.ustadmobile.meshrabiya.log.MNetLogger import com.ustadmobile.meshrabiya.testapp.appstate.AppUiState +import com.ustadmobile.meshrabiya.testapp.domain.AddNearbyNetworkUseCase import com.ustadmobile.meshrabiya.testapp.screens.InfoScreen import com.ustadmobile.meshrabiya.testapp.screens.LocalVirtualNodeScreen import com.ustadmobile.meshrabiya.testapp.screens.LogListScreen +import com.ustadmobile.meshrabiya.testapp.screens.NearbyTestRoute +import com.ustadmobile.meshrabiya.testapp.screens.NearbyTestScreen import com.ustadmobile.meshrabiya.testapp.screens.NeighborNodeListScreen import com.ustadmobile.meshrabiya.testapp.screens.OpenSourceLicensesScreen import com.ustadmobile.meshrabiya.testapp.screens.ReceiveScreen import com.ustadmobile.meshrabiya.testapp.screens.SelectDestNodeScreen import com.ustadmobile.meshrabiya.testapp.screens.SendFileScreen import com.ustadmobile.meshrabiya.testapp.theme.HttpOverBluetoothTheme +import com.ustadmobile.meshrabiya.testapp.viewmodel.NearbyTestViewModel +import com.ustadmobile.meshrabiya.testapp.viewmodel.VpnTestViewModel import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.di.compose.withDI +import org.kodein.di.direct +import org.kodein.di.instance import java.net.URLEncoder -import java.util.UUID class VNetTestActivity : ComponentActivity(), DIAware { - override val di by closestDI() + private val viewModel: VpnTestViewModel by instance() + private val VPN_REQUEST_CODE = 1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { HttpOverBluetoothTheme { - // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - MeshrabiyaTestApp(di) +// VpnTestScreen(viewModel = viewModel, onStartVpn = { startVpn() }) + MeshrabiyaTestApp(di = di) } } } } + private fun startVpn() { + val intent = viewModel.prepareVpn() + if (intent != null) { + startActivityForResult(intent, VPN_REQUEST_CODE) + } else { + onActivityResult(VPN_REQUEST_CODE, RESULT_OK, null) + } + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == VPN_REQUEST_CODE) { + if (resultCode == RESULT_OK) { + viewModel.startVpn() + Toast.makeText(this, "VPN permission granted", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "VPN permission denied", Toast.LENGTH_SHORT).show() + } + } + } } - @OptIn(ExperimentalMaterial3Api::class) @Composable fun MeshrabiyaTestApp( di: DI ) = withDI(di) { val navController: NavHostController = rememberNavController() + val logger: MNetLogger = di.direct.instance() + + val permissionRequest = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { grantResult -> + if(grantResult.all { it.value }) { + //permissions were accepted + logger(Log.WARN, "Meshrabiya: Permissions granted") + val addNearbyUseCase: AddNearbyNetworkUseCase = di.direct.instance() + addNearbyUseCase() + }else { + logger(Log.WARN, "Meshrabiya: Permissions not granted") + } + } + + LaunchedEffect(Unit) { + logger(Log.INFO, "Launching permission request") + permissionRequest.launch( + arrayOf( + NEARBY_WIFI_PERMISSION_NAME, + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + ) + ) + } + var appUiState: AppUiState by remember { mutableStateOf(AppUiState()) } @@ -102,7 +165,7 @@ fun MeshrabiyaTestApp( }) }, floatingActionButton = { - if(appUiState.fabState.visible) { + if (appUiState.fabState.visible) { ExtendedFloatingActionButton( onClick = appUiState.fabState.onClick, icon = { @@ -134,7 +197,7 @@ fun MeshrabiyaTestApp( ) NavigationBarItem( - selected = navController.currentDestination?.route == "network" , + selected = navController.currentDestination?.route == "network", label = { Text("Network") }, onClick = { navController.navigate("neighbornodes") @@ -147,8 +210,25 @@ fun MeshrabiyaTestApp( } ) + + NavigationBarItem( + selected = navController.currentDestination?.route == "chat", + label = { Text("Chat") }, + onClick = { + navController.navigate("chat") + }, + icon = { + Icon( + imageVector = Icons.Default.Chat, + contentDescription = null, + ) + } + ) + + + NavigationBarItem( - selected = selectedItem == "send" , + selected = selectedItem == "send", label = { Text("Send") }, onClick = { navController.navigate("send") @@ -162,7 +242,7 @@ fun MeshrabiyaTestApp( ) NavigationBarItem( - selected = selectedItem == "receive" , + selected = selectedItem == "receive", label = { Text("Receive") }, onClick = { navController.navigate("receive") @@ -176,7 +256,7 @@ fun MeshrabiyaTestApp( ) NavigationBarItem( - selected = selectedItem == "info" , + selected = selectedItem == "info", label = { Text("Info") }, onClick = { navController.navigate("Info") @@ -214,7 +294,7 @@ fun AppNavHost( startDestination: String = "localvirtualnode", onSetAppUiState: (AppUiState) -> Unit = { }, snackbarHostState: SnackbarHostState, -){ +) { NavHost( modifier = modifier, navController = navController, @@ -233,10 +313,24 @@ fun AppNavHost( ) } + composable("chat") { + NearbyTestRoute( + onSetAppUiState = onSetAppUiState + ) + } + + composable("send") { SendFileScreen( - onNavigateToSelectReceiveNode = {uri -> - navController.navigate("selectdestnode/${URLEncoder.encode(uri.toString(), "UTF-8")}") + onNavigateToSelectReceiveNode = { uri -> + navController.navigate( + "selectdestnode/${ + URLEncoder.encode( + uri.toString(), + "UTF-8" + ) + }" + ) }, onSetAppUiState = onSetAppUiState, ) @@ -250,7 +344,7 @@ fun AppNavHost( navigateOnDone = { navController.popBackStack() }, - onSetAppUiState = onSetAppUiState, + onSetAppUiState = onSetAppUiState, ) } diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VpnServiceManager.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VpnServiceManager.kt new file mode 100644 index 0000000..d205bc7 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/VpnServiceManager.kt @@ -0,0 +1,22 @@ +package com.ustadmobile.meshrabiya.testapp + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import com.meshrabiya.lib_vpn.MeshrabiyaVpnService + +class VpnServiceManager(private val context: Context) { + fun prepareVpn(): Intent? { + return VpnService.prepare(context) + } + + fun startVpn() { + val intent = Intent(context, MeshrabiyaVpnService::class.java) + context.startService(intent) + } + + fun stopVpn() { + val intent = Intent(context, MeshrabiyaVpnService::class.java) + context.stopService(intent) + } +} \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/domain/AddNearbyNetworkUseCase.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/domain/AddNearbyNetworkUseCase.kt new file mode 100644 index 0000000..dfce3b0 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/domain/AddNearbyNetworkUseCase.kt @@ -0,0 +1,36 @@ +package com.ustadmobile.meshrabiya.testapp.domain + +import android.content.Context +import com.meshrabiya.lib_nearby.nearby.NearbyVirtualNetwork +import com.ustadmobile.meshrabiya.ext.requireAddressAsInt +import com.ustadmobile.meshrabiya.log.MNetLogger +import com.ustadmobile.meshrabiya.testapp.viewmodel.NearbyTestViewModel.Companion.DEVICE_NAME_SUFFIX_LIMIT +import com.ustadmobile.meshrabiya.testapp.viewmodel.NearbyTestViewModel.Companion.NETWORK_SERVICE_ID +import com.ustadmobile.meshrabiya.vnet.VirtualNode +import java.net.InetAddress +import kotlin.random.Random + +class AddNearbyNetworkUseCase( + private val virtualNode: VirtualNode, + private val context: Context, + private val logger: MNetLogger +) { + + operator fun invoke() { + virtualNode.addVirtualNetworkInterface( + NearbyVirtualNetwork( + context = context, + name = "Device-${Random.nextInt(DEVICE_NAME_SUFFIX_LIMIT)}", + serviceId = NETWORK_SERVICE_ID, + virtualIpAddress = virtualNode.addressAsInt, + broadcastAddress = InetAddress.getByName("255.255.255.255").requireAddressAsInt(), + logger = logger, + ) { virtualPacket -> + virtualNode.route(virtualPacket) + }.also { + it.start() + } + ) + } + +} \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/NearbyTestScreen.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/NearbyTestScreen.kt new file mode 100644 index 0000000..38a1a3b --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/NearbyTestScreen.kt @@ -0,0 +1,209 @@ +package com.ustadmobile.meshrabiya.testapp.screens + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ustadmobile.meshrabiya.testapp.ViewModelFactory +import com.ustadmobile.meshrabiya.testapp.appstate.AppUiState +import com.ustadmobile.meshrabiya.testapp.viewmodel.NearbyTestUiState +import com.ustadmobile.meshrabiya.testapp.viewmodel.NearbyTestViewModel +import org.kodein.di.compose.localDI + +@Composable +fun NearbyTestRoute( + viewModel: NearbyTestViewModel = viewModel( + factory = ViewModelFactory( + di = localDI(), + owner = LocalSavedStateRegistryOwner.current, + vmFactory = { + NearbyTestViewModel(it) + }, + defaultArgs = null, + ) + ), + onSetAppUiState: (AppUiState) -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.appUiState) { + onSetAppUiState(uiState.appUiState) + } + + NearbyTestScreen( + uiState = uiState, + onSendMessage = viewModel::sendMessage, + onStartNetwork = viewModel::startNetwork, + onStopNetwork = viewModel::stopNetwork + ) +} + +@Composable +fun NearbyTestScreen( + uiState: NearbyTestUiState, + onSendMessage: (String) -> Unit, + onStartNetwork: () -> Unit, + onStopNetwork: () -> Unit +) { + var messageText by remember { mutableStateOf("") } + val logScrollState = rememberLazyListState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Button( + onClick = { + if (uiState.isNetworkRunning) onStopNetwork() else onStartNetwork() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text(if (uiState.isNetworkRunning) "Stop Network" else "Start Network") + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Discovered Nodes + Text( + text = "Discovered Nodes: ${ + uiState.discoveredEndpoints.joinToString(", ") { + it.ipAddress?.hostAddress ?: "Unknown IP" + } + }", + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Connected Nodes + Text( + text = "Connected Nodes: ${ + uiState.connectedEndpoints.joinToString(", ") { + it.ipAddress?.hostAddress ?: "Unknown IP" + } + }", + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Messages section + Text("Messages:", style = MaterialTheme.typography.titleMedium) + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) + .padding(8.dp), + contentPadding = PaddingValues(bottom = 8.dp) + ) { + items(uiState.messages) { message -> + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = "Sender: ${message.sender}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = message.message, + style = MaterialTheme.typography.bodyLarge + ) + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + ) + } + } + } + + // Message input + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = messageText, + onValueChange = { messageText = it }, + label = { Text("Message") }, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + if (messageText.isNotBlank()) { + onSendMessage(messageText) // Use the passed function + messageText = "" + } + }, + enabled = uiState.isNetworkRunning && messageText.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("Send") + } + } + + // Logs section + Spacer(modifier = Modifier.height(16.dp)) + Text("Logs:", style = MaterialTheme.typography.titleMedium) + LazyColumn( + state = logScrollState, + modifier = Modifier + .height(100.dp) + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + items(uiState.logs) { log -> + Text(log, style = MaterialTheme.typography.bodySmall) + } + } + + LaunchedEffect(uiState.logs.size) { + if (uiState.logs.isNotEmpty()) { + logScrollState.animateScrollToItem(uiState.logs.size - 1) + } + } + } +} + + + + + + + diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/VpnTestScreen.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/VpnTestScreen.kt new file mode 100644 index 0000000..9eb3be0 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/screens/VpnTestScreen.kt @@ -0,0 +1,56 @@ +package com.ustadmobile.meshrabiya.testapp.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.ustadmobile.meshrabiya.testapp.viewmodel.VpnStatus.CONNECTED +import com.ustadmobile.meshrabiya.testapp.viewmodel.VpnStatus.DISCONNECTED +import com.ustadmobile.meshrabiya.testapp.viewmodel.VpnTestViewModel + + +@Composable +fun VpnTestScreen(viewModel: VpnTestViewModel, onStartVpn: () -> Unit) { + val vpnStatus by viewModel.vpnStatus.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "VPN Status: ${vpnStatus.name}", + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + when (vpnStatus) { + DISCONNECTED -> onStartVpn() + CONNECTED -> viewModel.stopVpn() + } + } + ) { + Text(if (vpnStatus == DISCONNECTED) "Start VPN" else "Stop VPN") + } + + Spacer(modifier = Modifier.height(16.dp)) + + + } +} \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/ChatServer.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/ChatServer.kt new file mode 100644 index 0000000..869a751 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/ChatServer.kt @@ -0,0 +1,100 @@ + +package com.ustadmobile.meshrabiya.testapp.server + + +import android.util.Log +import com.meshrabiya.lib_nearby.nearby.NearbyVirtualNetwork +import com.ustadmobile.meshrabiya.log.MNetLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.net.DatagramPacket +import java.net.InetAddress +import java.net.InetSocketAddress + +class ChatServer( + private val nearbyNetwork: NearbyVirtualNetwork, + private val logger: MNetLogger +) { + private val _chatMessages = MutableStateFlow>(emptyList()) + val chatMessages = _chatMessages.asStateFlow() + + private val socket: MeshrabiyaDatagramSocket by lazy { + MeshrabiyaDatagramSocket(nearbyNetwork).apply { + bind(InetSocketAddress(UDP_PORT)) + setMessageHandler { message -> + processReceivedMessage(message.toByteArray()) + } + } + } + + fun sendMessage(message: String) { + if (message.isBlank()) return + + try { + // Format: "senderIP|message" + val messageText = "${nearbyNetwork.virtualAddress.hostAddress}|$message" + val data = messageText.toByteArray() + + // Create broadcast packet + val packet = DatagramPacket( + data, + data.size, + InetAddress.getByName("255.255.255.255"), + UDP_PORT + ) + + // Add message to local chat immediately + val chatMessage = ChatMessage( + timestamp = System.currentTimeMillis(), + sender = nearbyNetwork.virtualAddress.hostAddress, + message = message + ) + _chatMessages.update { it + chatMessage } + + // Send via socket + socket.send(packet) + logger(Log.INFO, "Message sent: $message") + } catch (e: Exception) { + logger(Log.ERROR, "Failed to send message", e) + } + } + + fun processReceivedMessage(messageData: ByteArray) { + try { + val message = String(messageData, Charsets.UTF_8) + val parts = message.split("|") + + if (parts.size == 2) { + val (sender, messageText) = parts + + // Only process messages from others (we already added our own in sendMessage) + if (sender != nearbyNetwork.virtualAddress.hostAddress) { + val chatMessage = ChatMessage( + timestamp = System.currentTimeMillis(), + sender = sender, + message = messageText + ) + _chatMessages.update { it + chatMessage } + logger(Log.INFO, "Message received from $sender: $messageText") + } + } + } catch (e: Exception) { + logger(Log.ERROR, "Error processing received message", e) + } + } + + fun close() { + socket.close() + } + + companion object { + const val UDP_PORT = 8888 + } +} + +data class ChatMessage( + val sender: String, + val message: String, + val timestamp: Long +) \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/MeshrabiyaDatagramSocket.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/MeshrabiyaDatagramSocket.kt new file mode 100644 index 0000000..dff0046 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/server/MeshrabiyaDatagramSocket.kt @@ -0,0 +1,54 @@ +package com.ustadmobile.meshrabiya.testapp.server + +import com.meshrabiya.lib_nearby.nearby.NearbyVirtualNetwork +import com.ustadmobile.meshrabiya.vnet.VirtualPacket +import com.ustadmobile.meshrabiya.vnet.VirtualPacketHeader +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.SocketAddress +import java.net.SocketException +import java.nio.ByteBuffer + +/** + * Socket implementation that works with the virtual network + */ +class MeshrabiyaDatagramSocket(private val nearbyNetwork: NearbyVirtualNetwork) : DatagramSocket((null as SocketAddress?)) { + private var messageHandler: ((String) -> Unit)? = null + private var isBound = false + + fun setMessageHandler(handler: (String) -> Unit) { + messageHandler = handler + } + + override fun bind(addr: SocketAddress?) { + if (!isBound) { + super.bind(addr) + isBound = true + } + } + + override fun send(packet: DatagramPacket) { + if (!isBound) { + throw SocketException("Socket not bound") + } + + val virtualPacket = VirtualPacket.fromHeaderAndPayloadData( + header = VirtualPacketHeader( + fromAddr = ByteBuffer.wrap(nearbyNetwork.virtualAddress.address).int, + toAddr = ByteBuffer.wrap(packet.address.address).int, + fromPort = localPort, + toPort = packet.port, + payloadSize = packet.length, + hopCount = 0, + maxHops = 10, + lastHopAddr = ByteBuffer.wrap(nearbyNetwork.virtualAddress.address).int + ), + data = ByteArray(VirtualPacket.VIRTUAL_PACKET_BUF_SIZE), + payloadOffset = VirtualPacketHeader.HEADER_SIZE + ) + + System.arraycopy(packet.data, packet.offset, virtualPacket.data, virtualPacket.payloadOffset, packet.length) + nearbyNetwork.send(virtualPacket, packet.address) + } + +} \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/NearbyTestViewModel.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/NearbyTestViewModel.kt new file mode 100644 index 0000000..4fb6cdc --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/NearbyTestViewModel.kt @@ -0,0 +1,282 @@ +package com.ustadmobile.meshrabiya.testapp.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.meshrabiya.lib_nearby.nearby.NearbyVirtualNetwork +import com.ustadmobile.meshrabiya.log.MNetLogger +import com.ustadmobile.meshrabiya.testapp.appstate.AppUiState +import com.ustadmobile.meshrabiya.testapp.appstate.FabState +import com.ustadmobile.meshrabiya.testapp.server.ChatMessage +import com.ustadmobile.meshrabiya.testapp.server.ChatServer +import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode +import com.ustadmobile.meshrabiya.vnet.VirtualPacket +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.kodein.di.DI +import org.kodein.di.instance +import java.net.InetAddress +import java.nio.ByteBuffer +import kotlin.random.Random + +class NearbyTestViewModel( + di: DI +) : ViewModel() { + private val _uiState = MutableStateFlow(NearbyTestUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val virtualNode: AndroidVirtualNode by di.instance() + private var nearbyNetwork: NearbyVirtualNetwork? = null + private var chatServer: ChatServer? = null + private var isNetworkInitialized = false + + private val logger = object : MNetLogger() { + override fun invoke(priority: Int, message: String, exception: Exception?) { + val logMessage = "${MNetLogger.priorityLabel(priority)}: $message" + viewModelScope.launch { + _uiState.update { currentState -> + currentState.copy(logs = currentState.logs + logMessage) + } + } + if (exception != null) { + Log.e(TAG_NEARBY_TEST, message, exception) + } + } + + override fun invoke(priority: Int, message: () -> String, exception: Exception?) { + invoke(priority, message(), exception) + } + } + + init { + initializeNearbyNetwork() + updateAppUiState() + } + + private fun initializeNearbyNetwork() { + try { + /* Disabled by Mike 4/Nov/24 + The nearby network should not be initialized by a viewmodel. This leads to a network + being started/created. + + nearbyNetwork = NearbyVirtualNetwork( + context = virtualNode.appContext, + name = "Device-${Random.nextInt(DEVICE_NAME_SUFFIX_LIMIT)}", + serviceId = NETWORK_SERVICE_ID, + virtualIpAddress = virtualNode.addressAsInt, + broadcastAddress = ipToInt(BROADCAST_IP_ADDRESS), + logger = logger + ) { packet -> + handleIncomingPacket(packet) + } + nearbyNetwork?.let { nearby -> + virtualNode.addVirtualNetworkInterface(nearby) + } + + chatServer = nearbyNetwork?.let { network -> + ChatServer(network, logger).also { server -> + observeChatMessages(server) + } + } + + logger(Log.INFO, "Network initialized with IP: ${virtualNode.address.hostAddress}") + */ + } catch (e: Exception) { + logger(Log.ERROR, "Failed to initialize network", e) + } + } + + private fun observeEndpoints() { + viewModelScope.launch { + try { + nearbyNetwork?.endpointStatusFlow?.collect { endpointMap -> + // Get discovered endpoints + val discoveredEndpoints = endpointMap.values + .distinctBy { it.ipAddress?.hostAddress } + .filter { it.ipAddress != null } + + // Get connected endpoints + val connectedEndpoints = endpointMap.values + .filter { it.status == NearbyVirtualNetwork.EndpointStatus.CONNECTED } + .distinctBy { it.ipAddress?.hostAddress } + + _uiState.update { it.copy( + discoveredEndpoints = discoveredEndpoints, + connectedEndpoints = connectedEndpoints + ) } + } + } catch (e: Exception) { + logger(Log.ERROR, "Error observing endpoints", e) + } + } + } + private fun observeChatMessages(server: ChatServer) { + viewModelScope.launch { + try { + server.chatMessages.collect { messages -> + _uiState.update { it.copy(messages = messages) } + messages.lastOrNull()?.let { lastMessage -> + logger( + Log.INFO, + "Received message from ${lastMessage.sender}: ${lastMessage.message}" + ) + } + } + } catch (e: Exception) { + logger(Log.ERROR, "Error observing chat messages", e) + } + } + } + + private fun handleIncomingPacket(packet: VirtualPacket) { + try { + logger(Log.DEBUG, "Received virtual packet: ${packet.header}") + + if (packet.header.toPort == ChatServer.UDP_PORT) { + val messageData = ByteArray(packet.header.payloadSize).apply { + System.arraycopy(packet.data, packet.payloadOffset, this, 0, packet.header.payloadSize) + } + chatServer?.processReceivedMessage(messageData) + } + } catch (e: Exception) { + logger(Log.ERROR, "Error handling incoming packet", e) + } + } + + fun startNetwork() { + /* + Disabled by Mike 4/Nov/24. As above, virtual network should not be initialized in a viewmodel + if (isNetworkInitialized) { + logger(Log.INFO, "Network is already running") + return + } + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + retryStartNetwork() + }) { + try { + nearbyNetwork?.start() + _uiState.update { it.copy(isNetworkRunning = true) } + isNetworkInitialized = true + observeEndpoints() + logger(Log.INFO, "Network started successfully with IP: ${nearbyNetwork?.virtualAddress?.hostAddress}") + } catch (e: Exception) { + logger(Log.ERROR, "Failed to start network", e) + retryStartNetwork() + } + } + + */ + } + + private fun retryStartNetwork() { + viewModelScope.launch { + delay(RETRY_DELAY) + logger(Log.INFO, "Retrying to start network...") + startNetwork() + } + } + + // Add this function to update AppUiState + private fun updateAppUiState() { + _uiState.update { prev -> + prev.copy( + appUiState = AppUiState( + title = "Nearby Network", + fabState = FabState( + visible = false + ) + ) + ) + } + } + + fun stopNetwork() { + if (!isNetworkInitialized) { + logger(Log.INFO, "Network is not running") + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + chatServer?.close() + nearbyNetwork?.close() + resetState() + updateAppUiState() // Make sure AppUiState is maintained + logger(Log.INFO, "Network stopped successfully") + } catch (e: Exception) { + logger(Log.ERROR, "Failed to stop network", e) + } + } + } + + private fun resetState() { + _uiState.update { prev -> + NearbyTestUiState().copy( + appUiState = prev.appUiState // Keep the AppUiState + ) + } + isNetworkInitialized = false + } + + fun sendMessage(message: String) { + val trimmedMessage = message.trim() + if (trimmedMessage.isEmpty()) { + logger(Log.INFO, "Empty message not sent") + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + chatServer?.sendMessage(trimmedMessage) + logger(Log.INFO, "Sent message: $trimmedMessage") + } catch (e: Exception) { + logger(Log.ERROR, "Failed to send message", e) + } + } + } + + private fun ipToInt(ipAddress: String): Int { + return try { + val inetAddress = InetAddress.getByName(ipAddress) + ByteBuffer.wrap(inetAddress.address).int + } catch (e: Exception) { + logger(Log.ERROR, "Failed to convert IP address", e) + throw e + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch { + try { + stopNetwork() + } catch (e: Exception) { + logger(Log.ERROR, "Error during ViewModel cleanup", e) + } + } + } + + companion object { + const val TAG_NEARBY_TEST = "NearbyTestViewModel" + const val BROADCAST_IP_ADDRESS = "255.255.255.255" + const val NETWORK_SERVICE_ID = "com.ustadmobile.meshrabiya.test" + const val DEVICE_NAME_SUFFIX_LIMIT = 1000 + const val RETRY_DELAY = 5000L + } +} + +data class NearbyTestUiState( + val appUiState: AppUiState = AppUiState(), + val isNetworkRunning: Boolean = false, + val discoveredEndpoints: List = emptyList(), + val connectedEndpoints: List = emptyList(), + val logs: List = emptyList(), + val messages: List = emptyList() +) \ No newline at end of file diff --git a/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/VpnTestViewModel.kt b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/VpnTestViewModel.kt new file mode 100644 index 0000000..d110313 --- /dev/null +++ b/test-app/src/main/java/com/ustadmobile/meshrabiya/testapp/viewmodel/VpnTestViewModel.kt @@ -0,0 +1,38 @@ +package com.ustadmobile.meshrabiya.testapp.viewmodel + +import android.app.Application +import android.content.Intent +import androidx.lifecycle.AndroidViewModel +import com.ustadmobile.meshrabiya.testapp.VpnServiceManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + + +class VpnTestViewModel(application: Application) : AndroidViewModel(application) { + private val vpnServiceManager = VpnServiceManager(application) + private val _vpnStatus = MutableStateFlow(VpnStatus.DISCONNECTED) + val vpnStatus: StateFlow = _vpnStatus + + fun prepareVpn(): Intent? { + return vpnServiceManager.prepareVpn() + } + + fun startVpn() { + vpnServiceManager.startVpn() + _vpnStatus.value = VpnStatus.CONNECTED + } + + fun stopVpn() { + vpnServiceManager.stopVpn() + _vpnStatus.value = VpnStatus.DISCONNECTED + } + +} + +enum class VpnStatus { + DISCONNECTED, CONNECTED +} + + + + diff --git a/test-shared/build.gradle b/test-shared/build.gradle index f891e3b..df3f40d 100644 --- a/test-shared/build.gradle +++ b/test-shared/build.gradle @@ -36,12 +36,13 @@ android { dependencies { implementation project(':lib-meshrabiya') - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'junit:junit:4.13.2' + + implementation libs.androidx.core + implementation libs.junit implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version_coroutines" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$version_kotlinx_serialization" - implementation "org.mockito.kotlin:mockito-kotlin:$version_kotlin_mockito" + implementation libs.mockito.kotlin implementation "app.cash.turbine:turbine:$version_turbine" - implementation "com.squareup.okhttp3:mockwebserver:$version_mockwebserver" - implementation "com.squareup.okhttp3:okhttp:$version_okhttp" + implementation libs.mockwebserver + implementation libs.okhttp } \ No newline at end of file