11 · Android
android/app/src/main/kotlin/com/sophax/sophaxchat/network/NearbyManager.kt
180 lines · Kotlin
1package com.sophax.sophaxchat.network
2
3import android.content.Context
4import com.google.android.gms.nearby.Nearby
5import com.google.android.gms.nearby.connection.AdvertisingOptions
6import com.google.android.gms.nearby.connection.ConnectionInfo
7import com.google.android.gms.nearby.connection.ConnectionLifecycleCallback
8import com.google.android.gms.nearby.connection.ConnectionResolution
9import com.google.android.gms.nearby.connection.ConnectionsStatusCodes
10import com.google.android.gms.nearby.connection.DiscoveredEndpointInfo
11import com.google.android.gms.nearby.connection.DiscoveryOptions
12import com.google.android.gms.nearby.connection.EndpointDiscoveryCallback
13import com.google.android.gms.nearby.connection.Payload
14import com.google.android.gms.nearby.connection.PayloadCallback
15import com.google.android.gms.nearby.connection.PayloadTransferUpdate
16import com.google.android.gms.nearby.connection.Strategy
17import com.sophax.sophaxchat.protocol.WireMessage
18import kotlinx.serialization.encodeToString
19import kotlinx.serialization.json.Json
20import kotlinx.serialization.decodeFromString
21import java.util.concurrent.ConcurrentHashMap
22
23// ---------------------------------------------------------------------------
24// Listener interface — mirrors iOS MeshManagerDelegate
25// ---------------------------------------------------------------------------
26
27interface NearbyManagerListener {
28 fun didDiscoverPeer(endpointId: String, displayName: String)
29 fun didLosePeer(endpointId: String)
30 fun didConnectToPeer(endpointId: String)
31 fun didDisconnectFromPeer(endpointId: String)
32 fun didReceiveMessage(message: WireMessage, fromEndpointId: String)
33 fun sendDidFail(endpointId: String, error: Exception)
34 fun helloMessage(): WireMessage? // called on connect to get Hello wire message
35}
36
37// ---------------------------------------------------------------------------
38// NearbyManager — Android equivalent of iOS MeshManager (MultipeerConnectivity)
39//
40// Uses Google Nearby Connections API:
41// Strategy.P2P_CLUSTER = many-to-many mesh (matches MPC behavior)
42// Service ID: "com.sophax.sophaxchat"
43// ---------------------------------------------------------------------------
44
45class NearbyManager(private val context: Context) {
46
47 private val client = Nearby.getConnectionsClient(context)
48 private val json = Json { ignoreUnknownKeys = true }
49
50 var listener: NearbyManagerListener? = null
51
52 // endpoint ID → display name (for discovery UI)
53 private val discoveredEndpoints = ConcurrentHashMap<String, String>()
54 // endpoint ID → connection state
55 private val connectedEndpoints = ConcurrentHashMap<String, Boolean>()
56
57 private val SERVICE_ID = "com.sophax.sophaxchat"
58
59 // -----------------------------------------------------------------------
60 // Start (advertising + discovery)
61 // -----------------------------------------------------------------------
62
63 fun start(displayName: String) {
64 startAdvertising(displayName)
65 startDiscovery()
66 }
67
68 private fun startAdvertising(displayName: String) {
69 val options = AdvertisingOptions.Builder()
70 .setStrategy(Strategy.P2P_CLUSTER)
71 .build()
72 client.startAdvertising(displayName, SERVICE_ID, connectionLifecycleCallback, options)
73 .addOnFailureListener { /* advertising may fail if already running */ }
74 }
75
76 private fun startDiscovery() {
77 val options = DiscoveryOptions.Builder()
78 .setStrategy(Strategy.P2P_CLUSTER)
79 .build()
80 client.startDiscovery(SERVICE_ID, endpointDiscoveryCallback, options)
81 .addOnFailureListener { /* discovery may fail if already running */ }
82 }
83
84 fun stop() {
85 client.stopAdvertising()
86 client.stopDiscovery()
87 client.stopAllEndpoints()
88 connectedEndpoints.clear()
89 discoveredEndpoints.clear()
90 }
91
92 // -----------------------------------------------------------------------
93 // Send / Broadcast
94 // -----------------------------------------------------------------------
95
96 fun send(message: WireMessage, toEndpointId: String) {
97 val bytes = json.encodeToString(message).toByteArray(Charsets.UTF_8)
98 client.sendPayload(toEndpointId, Payload.fromBytes(bytes))
99 .addOnFailureListener { e ->
100 listener?.sendDidFail(toEndpointId, e)
101 }
102 }
103
104 fun broadcast(message: WireMessage, excluding: String? = null) {
105 connectedEndpoints.keys
106 .filter { it != excluding }
107 .forEach { send(message, it) }
108 }
109
110 fun connectedEndpointIDs(): Set<String> = connectedEndpoints.keys.toSet()
111 fun isConnected(endpointId: String) = connectedEndpoints.containsKey(endpointId)
112
113 // -----------------------------------------------------------------------
114 // Endpoint discovery
115 // -----------------------------------------------------------------------
116
117 private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback() {
118 override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) {
119 discoveredEndpoints[endpointId] = info.endpointName
120 listener?.didDiscoverPeer(endpointId, info.endpointName)
121 // Auto-request connection (same as iOS auto-invite behavior)
122 client.requestConnection(
123 context.packageName, // our display name for the remote side
124 endpointId,
125 connectionLifecycleCallback
126 ).addOnFailureListener { /* already connecting */ }
127 }
128
129 override fun onEndpointLost(endpointId: String) {
130 discoveredEndpoints.remove(endpointId)
131 listener?.didLosePeer(endpointId)
132 }
133 }
134
135 // -----------------------------------------------------------------------
136 // Connection lifecycle
137 // -----------------------------------------------------------------------
138
139 private val connectionLifecycleCallback = object : ConnectionLifecycleCallback() {
140 override fun onConnectionInitiated(endpointId: String, info: ConnectionInfo) {
141 // Auto-accept all incoming connections (same as iOS auto-accept)
142 client.acceptConnection(endpointId, payloadCallback)
143 }
144
145 override fun onConnectionResult(endpointId: String, result: ConnectionResolution) {
146 if (result.status.statusCode == ConnectionsStatusCodes.STATUS_OK) {
147 connectedEndpoints[endpointId] = true
148 listener?.didConnectToPeer(endpointId)
149 // Send Hello immediately (same as iOS behavior on MPC connect)
150 listener?.helloMessage()?.let { send(it, endpointId) }
151 }
152 }
153
154 override fun onDisconnected(endpointId: String) {
155 connectedEndpoints.remove(endpointId)
156 listener?.didDisconnectFromPeer(endpointId)
157 }
158 }
159
160 // -----------------------------------------------------------------------
161 // Payload (message) handling
162 // -----------------------------------------------------------------------
163
164 private val payloadCallback = object : PayloadCallback() {
165 override fun onPayloadReceived(endpointId: String, payload: Payload) {
166 if (payload.type != Payload.Type.BYTES) return
167 val bytes = payload.asBytes() ?: return
168 try {
169 val message = json.decodeFromString<WireMessage>(String(bytes, Charsets.UTF_8))
170 listener?.didReceiveMessage(message, endpointId)
171 } catch (e: Exception) {
172 // Malformed packet — silently drop
173 }
174 }
175
176 override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
177 // Not needed for byte payloads
178 }
179 }
180}