diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b69c46e --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.schoolmesh.messenger" + compileSdk = 34 + + defaultConfig { + applicationId = "com.schoolmesh.messenger" + minSdk = 26 + targetSdk = 34 + versionCode = 2 + versionName = "0.1.1" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + 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("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..634a9fa --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Project specific ProGuard rules go here. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9825773 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt b/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt new file mode 100644 index 0000000..0daaf99 --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/MainActivity.kt @@ -0,0 +1,165 @@ +package com.schoolmesh.messenger + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.schoolmesh.messenger.mesh.MeshForegroundService +import com.schoolmesh.messenger.mesh.MeshServiceContract + +class MainActivity : AppCompatActivity() { + private lateinit var statusText: TextView + private lateinit var peersText: TextView + private lateinit var logsText: TextView + + private val peers = linkedSetOf() + private val logs = ArrayDeque() + + private val meshEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != MeshServiceContract.ACTION_EVENT) return + val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return + val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return + + when (eventType) { + MeshServiceContract.EVENT_STATUS -> updateStatus(value) + MeshServiceContract.EVENT_PEER -> addPeer(value) + MeshServiceContract.EVENT_LOG -> appendLog(value) + } + } + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + val allGranted = result.values.all { it } + if (allGranted) { + startMesh() + } else { + updateStatus("Нет BLE-разрешений") + appendLog("Permissions denied by user") + Toast.makeText(this, "Разрешения отклонены", Toast.LENGTH_SHORT).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + statusText = findViewById(R.id.statusText) + peersText = findViewById(R.id.peersText) + logsText = findViewById(R.id.logsText) + + findViewById(R.id.btnStartMesh).setOnClickListener { + ensurePermissionsAndStart() + } + findViewById(R.id.btnStopMesh).setOnClickListener { + MeshForegroundService.stop(this) + updateStatus("Mesh остановлен") + appendLog("Mesh service stop requested") + } + + renderPeers() + renderLogs() + } + + override fun onStart() { + super.onStart() + registerMeshReceiver() + } + + override fun onStop() { + unregisterReceiver(meshEventReceiver) + super.onStop() + } + + private fun registerMeshReceiver() { + val filter = IntentFilter(MeshServiceContract.ACTION_EVENT) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(meshEventReceiver, filter) + } + } + + private fun ensurePermissionsAndStart() { + val missing = requiredPermissions().filter { permission -> + ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + startMesh() + } else { + permissionLauncher.launch(missing.toTypedArray()) + } + } + + private fun requiredPermissions(): List { + val permissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions += Manifest.permission.BLUETOOTH_SCAN + permissions += Manifest.permission.BLUETOOTH_CONNECT + permissions += Manifest.permission.BLUETOOTH_ADVERTISE + } else { + permissions += Manifest.permission.ACCESS_FINE_LOCATION + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions += Manifest.permission.POST_NOTIFICATIONS + } + return permissions + } + + private fun startMesh() { + MeshForegroundService.start(this) + updateStatus("Запуск foreground service") + appendLog("Mesh service start requested") + Toast.makeText(this, "Mesh запускается", Toast.LENGTH_SHORT).show() + } + + private fun updateStatus(text: String) { + statusText.text = text + } + + private fun addPeer(address: String) { + if (peers.add(address)) { + renderPeers() + } + } + + private fun appendLog(message: String) { + if (logs.size >= MAX_LOG_ENTRIES) { + logs.removeFirst() + } + logs.addLast(message) + renderLogs() + } + + private fun renderPeers() { + peersText.text = if (peers.isEmpty()) { + "Узлы не найдены" + } else { + peers.joinToString(separator = "\n") + } + } + + private fun renderLogs() { + logsText.text = if (logs.isEmpty()) { + "Лог пуст" + } else { + logs.joinToString(separator = "\n") + } + } + + companion object { + private const val MAX_LOG_ENTRIES = 20 + } +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt new file mode 100644 index 0000000..8af01bf --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/BleMeshManager.kt @@ -0,0 +1,430 @@ +package com.schoolmesh.messenger.mesh + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.ParcelUuid +import android.util.Log +import androidx.core.content.ContextCompat +import java.nio.charset.StandardCharsets +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +class BleMeshManager( + private val context: Context, + private val onPeerDiscovered: (String) -> Unit = {}, + private val onStatusChanged: (String) -> Unit = {}, + private val onError: (String) -> Unit = {}, + private val onLog: (String) -> Unit = {}, + private val seenPacketCache: SeenPacketCache = SeenPacketCache() +) { + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private val bluetoothAdapter = bluetoothManager?.adapter + private val scanner: BluetoothLeScanner? + get() = bluetoothAdapter?.bluetoothLeScanner + private val advertiser: BluetoothLeAdvertiser? + get() = bluetoothAdapter?.bluetoothLeAdvertiser + + private val activeConnections = ConcurrentHashMap() + private var gattServer: BluetoothGattServer? = null + private var inboundCharacteristic: BluetoothGattCharacteristic? = null + private var isRunning = false + + private val localNodeId: String by lazy { + bluetoothAdapter?.address ?: "android-${UUID.randomUUID()}" + } + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device ?: return + val address = device.address ?: return + onPeerDiscovered(address) + if (address == localNodeId || activeConnections.containsKey(address)) { + return + } + log("Discovered BLE node: $address") + connectToPeer(device) + } + + override fun onScanFailed(errorCode: Int) { + fail("BLE scan failed: $errorCode") + } + } + + private val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + log("BLE advertising started") + } + + override fun onStartFailure(errorCode: Int) { + fail("BLE advertising failed: $errorCode") + } + } + + private val gattServerCallback = object : BluetoothGattServerCallback() { + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + log("GATT server connection ${device.address}: state=$newState status=$status") + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + if (characteristic.uuid != CHARACTERISTIC_PACKET_UUID) { + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null) + } + return + } + + val rawPacket = value.toString(StandardCharsets.UTF_8) + log("Packet received from ${device.address}: $rawPacket") + handleIncomingPacket(rawPacket) + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) + } + } + } + + private inner class MeshGattCallback( + private val device: BluetoothDevice + ) : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + val address = device.address ?: return + if (status != BluetoothGatt.GATT_SUCCESS) { + log("GATT client error for $address: status=$status") + closeConnection(address) + return + } + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + log("Connected to peer $address") + activeConnections[address] = gatt + gatt.discoverServices() + } + + BluetoothProfile.STATE_DISCONNECTED -> { + log("Disconnected from peer $address") + closeConnection(address) + } + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + log("Service discovery failed for ${device.address}: $status") + return + } + log("Services discovered for ${device.address}") + sendPresence(gatt) + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + val address = device.address ?: return + if (status == BluetoothGatt.GATT_SUCCESS) { + log("Packet sent to $address") + } else { + log("Packet send failed to $address: status=$status") + } + } + } + + fun onPacketReceived(packet: MeshPacket): MeshAction { + val isNew = seenPacketCache.markSeen(packet.messageId) + if (!isNew) { + return MeshAction.DropDuplicate + } + + if (packet.isExpired()) { + return MeshAction.DropExpired + } + + return when (packet.type) { + PacketType.ACK -> MeshAction.ConsumeAck(packet.messageId) + PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId) + PacketType.MESSAGE -> MeshAction.ProcessAndRelay(packet.decrementedTtl()) + } + } + + fun start() { + if (isRunning) return + if (!hasRequiredRuntimePermissions()) { + fail("BLE permissions are missing") + return + } + + val adapter = bluetoothAdapter + if (adapter == null || !adapter.isEnabled) { + fail("Bluetooth adapter is unavailable or disabled") + return + } + + startGattServer() + startScanning() + startAdvertising() + isRunning = true + onStatusChanged("Mesh активен, идет discovery и GATT transport") + log("BLE mesh manager started with nodeId=$localNodeId") + } + + fun stop() { + if (!isRunning) return + + runCatching { scanner?.stopScan(scanCallback) } + .onFailure { Log.w(TAG, "Failed to stop scan", it) } + runCatching { advertiser?.stopAdvertising(advertiseCallback) } + .onFailure { Log.w(TAG, "Failed to stop advertising", it) } + runCatching { gattServer?.close() } + .onFailure { Log.w(TAG, "Failed to close GATT server", it) } + + activeConnections.keys.toList().forEach(::closeConnection) + inboundCharacteristic = null + gattServer = null + isRunning = false + onStatusChanged("Mesh остановлен") + log("BLE mesh manager stopped") + } + + private fun handleIncomingPacket(rawPacket: String) { + val packet = runCatching { MeshPacketCodec.decode(rawPacket) } + .getOrElse { + fail("Packet decode failed: ${it.message}") + return + } + + when (val action = onPacketReceived(packet)) { + MeshAction.DropDuplicate -> log("Duplicate packet dropped: ${packet.messageId}") + MeshAction.DropExpired -> log("Expired packet dropped: ${packet.messageId}") + is MeshAction.ConsumeAck -> log("ACK consumed: ${action.messageId}") + is MeshAction.ConsumePresence -> { + onPeerDiscovered(action.senderId) + onStatusChanged("Presence from ${action.senderId}") + log("Presence consumed from ${action.senderId}") + } + + is MeshAction.ProcessAndRelay -> { + onStatusChanged("Message from ${packet.senderId}") + log("Relaying packet ${packet.messageId}") + broadcastPacket(action.packetToRelay) + sendAck(packet) + } + } + } + + @SuppressLint("MissingPermission") + private fun startGattServer() { + val manager = bluetoothManager ?: run { + fail("BluetoothManager unavailable") + return + } + + val server = manager.openGattServer(context, gattServerCallback) + if (server == null) { + fail("Failed to open GATT server") + return + } + + val characteristic = BluetoothGattCharacteristic( + CHARACTERISTIC_PACKET_UUID, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE + ) + + val service = BluetoothGattService( + MESH_SERVICE_UUID, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ).apply { + addCharacteristic(characteristic) + } + + server.addService(service) + gattServer = server + inboundCharacteristic = characteristic + log("GATT server started") + } + + @SuppressLint("MissingPermission") + private fun startScanning() { + val filter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(MESH_SERVICE_UUID)) + .build() + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + scanner?.startScan(listOf(filter), settings, scanCallback) + log("BLE scanning started") + } + + @SuppressLint("MissingPermission") + private fun startAdvertising() { + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(ParcelUuid(MESH_SERVICE_UUID)) + .build() + + advertiser?.startAdvertising(settings, data, advertiseCallback) + } + + @SuppressLint("MissingPermission") + private fun connectToPeer(device: BluetoothDevice) { + val address = device.address ?: return + if (activeConnections.containsKey(address)) return + log("Connecting to peer $address") + val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE) + } else { + @Suppress("DEPRECATION") + device.connectGatt(context, false, MeshGattCallback(device)) + } + if (gatt != null) { + activeConnections[address] = gatt + } + } + + private fun sendPresence(gatt: BluetoothGatt) { + val packet = MeshPacket( + senderId = localNodeId, + targetId = gatt.device.address ?: "broadcast", + type = PacketType.PRESENCE, + payload = "presence:$localNodeId" + ) + writePacket(gatt, packet) + } + + private fun sendAck(packet: MeshPacket) { + val ack = MeshPacket( + senderId = localNodeId, + targetId = packet.senderId, + type = PacketType.ACK, + payload = packet.messageId + ) + broadcastPacket(ack) + } + + private fun broadcastPacket(packet: MeshPacket) { + activeConnections.values.forEach { gatt -> + writePacket(gatt, packet) + } + } + + @SuppressLint("MissingPermission") + private fun writePacket(gatt: BluetoothGatt, packet: MeshPacket) { + val characteristic = gatt + .getService(MESH_SERVICE_UUID) + ?.getCharacteristic(CHARACTERISTIC_PACKET_UUID) + + if (characteristic == null) { + log("Remote characteristic missing on ${gatt.device.address}") + return + } + + val payload = MeshPacketCodec.encode(packet).toByteArray(StandardCharsets.UTF_8) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + characteristic, + payload, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + ) + } else { + @Suppress("DEPRECATION") + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + @Suppress("DEPRECATION") + characteristic.value = payload + @Suppress("DEPRECATION") + gatt.writeCharacteristic(characteristic) + } + } + + @SuppressLint("MissingPermission") + private fun closeConnection(address: String) { + val gatt = activeConnections.remove(address) ?: return + runCatching { + gatt.disconnect() + gatt.close() + }.onFailure { + Log.w(TAG, "Failed to close connection for $address", it) + } + } + + private fun hasRequiredRuntimePermissions(): Boolean { + val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + } else { + listOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + return requiredPermissions.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + } + + private fun fail(message: String) { + Log.e(TAG, message) + onError(message) + onLog(message) + } + + private fun log(message: String) { + Log.d(TAG, message) + onLog(message) + } + + companion object { + private const val TAG = "BleMeshManager" + private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b") + private val CHARACTERISTIC_PACKET_UUID: UUID = + UUID.fromString("f9629b10-9d60-4d95-bc6a-6fdb4d4f5a4a") + } +} + +sealed interface MeshAction { + data object DropDuplicate : MeshAction + data object DropExpired : MeshAction + data class ConsumeAck(val messageId: String) : MeshAction + data class ConsumePresence(val senderId: String) : MeshAction + data class ProcessAndRelay(val packetToRelay: MeshPacket) : MeshAction +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt new file mode 100644 index 0000000..8ae49cd --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshForegroundService.kt @@ -0,0 +1,119 @@ +package com.schoolmesh.messenger.mesh + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.schoolmesh.messenger.R + +class MeshForegroundService : Service() { + private lateinit var bleMeshManager: BleMeshManager + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + bleMeshManager = BleMeshManager( + context = applicationContext, + onPeerDiscovered = { address -> + sendEvent(MeshServiceContract.EVENT_PEER, address) + sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address") + }, + onStatusChanged = { status -> + sendEvent(MeshServiceContract.EVENT_STATUS, status) + updateNotification(status) + }, + onError = { message -> + sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") + sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message") + updateNotification("Ошибка mesh") + }, + onLog = { message -> + sendEvent(MeshServiceContract.EVENT_LOG, message) + } + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + MeshServiceContract.ACTION_STOP -> stopMesh() + else -> startMesh() + } + return START_STICKY + } + + override fun onDestroy() { + bleMeshManager.stop() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startMesh() { + startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается")) + bleMeshManager.start() + } + + private fun stopMesh() { + bleMeshManager.stop() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun buildNotification(contentText: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(contentText) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setOngoing(true) + .build() + } + + private fun updateNotification(contentText: String) { + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.notify(NOTIFICATION_ID, buildNotification(contentText)) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + + private fun sendEvent(type: String, value: String) { + sendBroadcast( + Intent(MeshServiceContract.ACTION_EVENT) + .setPackage(packageName) + .putExtra(MeshServiceContract.EXTRA_EVENT_TYPE, type) + .putExtra(MeshServiceContract.EXTRA_EVENT_VALUE, value) + ) + } + + companion object { + private const val CHANNEL_ID = "mesh_status" + private const val NOTIFICATION_ID = 1001 + + fun start(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { + action = MeshServiceContract.ACTION_START + } + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { + action = MeshServiceContract.ACTION_STOP + } + context.startService(intent) + } + } +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt new file mode 100644 index 0000000..fd9e516 --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacket.kt @@ -0,0 +1,21 @@ +package com.schoolmesh.messenger.mesh + +import java.util.UUID + +data class MeshPacket( + val messageId: String = UUID.randomUUID().toString(), + val senderId: String, + val targetId: String, + val ttl: Int = DEFAULT_TTL, + val timestamp: Long = System.currentTimeMillis(), + val type: PacketType, + val payload: String +) { + fun isExpired(): Boolean = ttl <= 0 + + fun decrementedTtl(): MeshPacket = copy(ttl = ttl - 1) + + companion object { + const val DEFAULT_TTL = 6 + } +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt new file mode 100644 index 0000000..4bb9b43 --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshPacketCodec.kt @@ -0,0 +1,30 @@ +package com.schoolmesh.messenger.mesh + +import org.json.JSONObject + +object MeshPacketCodec { + fun encode(packet: MeshPacket): String { + return JSONObject() + .put("messageId", packet.messageId) + .put("senderId", packet.senderId) + .put("targetId", packet.targetId) + .put("ttl", packet.ttl) + .put("timestamp", packet.timestamp) + .put("type", packet.type.name) + .put("payload", packet.payload) + .toString() + } + + fun decode(raw: String): MeshPacket { + val json = JSONObject(raw) + return MeshPacket( + messageId = json.getString("messageId"), + senderId = json.getString("senderId"), + targetId = json.getString("targetId"), + ttl = json.getInt("ttl"), + timestamp = json.getLong("timestamp"), + type = PacketType.valueOf(json.getString("type")), + payload = json.getString("payload") + ) + } +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt new file mode 100644 index 0000000..0d25c77 --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/MeshServiceContract.kt @@ -0,0 +1,14 @@ +package com.schoolmesh.messenger.mesh + +object MeshServiceContract { + const val ACTION_START = "com.schoolmesh.messenger.mesh.START" + const val ACTION_STOP = "com.schoolmesh.messenger.mesh.STOP" + const val ACTION_EVENT = "com.schoolmesh.messenger.mesh.EVENT" + + const val EXTRA_EVENT_TYPE = "event_type" + const val EXTRA_EVENT_VALUE = "event_value" + + const val EVENT_STATUS = "status" + const val EVENT_PEER = "peer" + const val EVENT_LOG = "log" +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt new file mode 100644 index 0000000..1f80edb --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/PacketType.kt @@ -0,0 +1,7 @@ +package com.schoolmesh.messenger.mesh + +enum class PacketType { + MESSAGE, + ACK, + PRESENCE +} diff --git a/android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt b/android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt new file mode 100644 index 0000000..a349c48 --- /dev/null +++ b/android/app/src/main/java/com/schoolmesh/messenger/mesh/SeenPacketCache.kt @@ -0,0 +1,21 @@ +package com.schoolmesh.messenger.mesh + +class SeenPacketCache( + private val maxSize: Int = 512 +) { + private val packetIds = LinkedHashSet() + + @Synchronized + fun markSeen(packetId: String): Boolean { + if (packetIds.contains(packetId)) return false + + packetIds.add(packetId) + if (packetIds.size > maxSize) { + val oldest = packetIds.firstOrNull() + if (oldest != null) { + packetIds.remove(oldest) + } + } + return true + } +} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..5a3e60d --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6d58576 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #1E6E54 + #A4F3D5 + #1150B4 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d3a7fe7 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + School Mesh Messenger + School Mesh Messenger + Mesh status + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f8bec9c --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..1b0854f --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,2 @@ + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3b58870 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,2 @@ + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..21e0834 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.4.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..7101f8e --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..5fbe536 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "SchoolMeshMessenger" +include(":app") diff --git a/website/assets/css/styles.css b/website/assets/css/styles.css new file mode 100644 index 0000000..d8c0fc8 --- /dev/null +++ b/website/assets/css/styles.css @@ -0,0 +1,32 @@ +:root { + --mesh-dark: #0f2a43; + --mesh-accent: #1f8a70; + --mesh-bg: #f4f8fb; + --mesh-text: #1d2630; +} + +body { + background: radial-gradient(circle at top right, #d7f6eb 0%, #f4f8fb 40%, #eef3fa 100%); + color: var(--mesh-text); +} + +.hero { + color: #fff; + background: linear-gradient(135deg, var(--mesh-dark), #144c72 50%, #196b95); +} + +.status-card { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(8px); +} + +.feature { + background: #fff; + border: 1px solid #dbe4ef; + height: 100%; +} + +footer { + border-top: 1px solid #dbe4ef; +} diff --git a/website/assets/js/app.js b/website/assets/js/app.js new file mode 100644 index 0000000..79d3940 --- /dev/null +++ b/website/assets/js/app.js @@ -0,0 +1,50 @@ +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('downloadBtn'); + const versionBadge = document.getElementById('versionBadge'); + const versionSummary = document.getElementById('versionSummary'); + const downloadMeta = document.getElementById('downloadMeta'); + if (!btn) return; + + btn.addEventListener('click', (event) => { + if (!btn.getAttribute('href') || btn.getAttribute('href') === '#') { + event.preventDefault(); + alert('APK пока не опубликован. Следите за обновлениями проекта.'); + } + }); + + fetch('assets/meta/version.json', { cache: 'no-store' }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then((meta) => { + const sizeMb = (meta.apkSizeBytes / (1024 * 1024)).toFixed(1); + const builtAt = new Date(meta.buildDateUtc).toLocaleString('ru-RU', { + dateStyle: 'medium', + timeStyle: 'short' + }); + + if (versionBadge) { + versionBadge.textContent = `v${meta.versionName} (${meta.versionCode})`; + } + if (versionSummary) { + versionSummary.textContent = `Последняя сборка опубликована ${builtAt} UTC.`; + } + if (downloadMeta) { + downloadMeta.textContent = `Доступна debug-сборка v${meta.versionName}. Размер APK: ${sizeMb} MB.`; + } + if (meta.apkPath) { + btn.setAttribute('href', meta.apkPath); + } + }) + .catch(() => { + if (versionBadge) { + versionBadge.textContent = 'Версия недоступна'; + } + if (versionSummary) { + versionSummary.textContent = 'Не удалось загрузить метаданные сборки.'; + } + }); +}); diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..5b621ba --- /dev/null +++ b/website/index.html @@ -0,0 +1,65 @@ + + + + + + School Mesh Messenger + + + + + + + + + + School Mesh Messenger + Офлайн-мессенджер для школы на базе BLE mesh-сети: связь между учениками и учителями без интернета. + + Скачать APK + Как это работает + + + + + Статус + Загрузка версии... + Получаем информацию о последней сборке. + + + + + + + + + + Что внутри + + BLE DiscoveryОбнаружение ближайших узлов и обмен пакетами. + Mesh RelayПередача сообщений hop-by-hop с TTL и ACK. + БезопасностьБазовое шифрование и защита от дубликатов. + + + + + + + Скачать + Доступна текущая debug-сборка Android-приложения. + Скачать APK + + + + + + + + + +
Офлайн-мессенджер для школы на базе BLE mesh-сети: связь между учениками и учителями без интернета.
Получаем информацию о последней сборке.
Обнаружение ближайших узлов и обмен пакетами.
Передача сообщений hop-by-hop с TTL и ACK.
Базовое шифрование и защита от дубликатов.
Доступна текущая debug-сборка Android-приложения.