Finish NNNet app shell, updates, and docs
Some checks failed
Android CI / build (push) Has been cancelled

This commit is contained in:
dom4k
2026-03-16 19:58:13 +00:00
parent 53fc4c1ff4
commit 3e22bb699e
25 changed files with 1619 additions and 127 deletions

View File

@@ -1,6 +1,7 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.kapt")
}
android {
@@ -41,4 +42,9 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
kapt("androidx.room:room-compiler:2.6.1")
}

View File

@@ -16,6 +16,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"

View File

@@ -1,28 +1,73 @@
package com.schoolmesh.messenger
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.switchmaterial.SwitchMaterial
import com.schoolmesh.messenger.data.ChatSummary
import com.schoolmesh.messenger.data.MeshDatabase
import com.schoolmesh.messenger.data.MeshRepository
import com.schoolmesh.messenger.mesh.MeshForegroundService
import com.schoolmesh.messenger.mesh.MeshServiceContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var statusText: TextView
private lateinit var peersText: TextView
private lateinit var logsText: TextView
private lateinit var activeChatTitle: TextView
private lateinit var targetInput: EditText
private lateinit var messageInput: EditText
private lateinit var chatListView: ListView
private lateinit var messageListView: ListView
private lateinit var chatsScreen: android.view.View
private lateinit var settingsScreen: android.view.View
private lateinit var autoUpdateSwitch: SwitchMaterial
private val peers = linkedSetOf<String>()
private val logs = ArrayDeque<String>()
private val chatSummaries = mutableListOf<ChatSummary>()
private val chatItems = mutableListOf<String>()
private val messageItems = mutableListOf<String>()
private lateinit var chatAdapter: ArrayAdapter<String>
private lateinit var messageAdapter: ArrayAdapter<String>
private var activePeerId: String? = null
private var pendingSend: PendingSend? = null
private var pendingStartRequested = false
private val prefs by lazy {
getSharedPreferences("nnnet_settings", Context.MODE_PRIVATE)
}
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -34,6 +79,10 @@ class MainActivity : AppCompatActivity() {
MeshServiceContract.EVENT_STATUS -> updateStatus(value)
MeshServiceContract.EVENT_PEER -> addPeer(value)
MeshServiceContract.EVENT_LOG -> appendLog(value)
MeshServiceContract.EVENT_MESSAGES_CHANGED -> {
refreshChats()
refreshMessages()
}
}
}
}
@@ -43,11 +92,23 @@ class MainActivity : AppCompatActivity() {
) { result ->
val allGranted = result.values.all { it }
if (allGranted) {
startMesh()
ensureBluetoothEnabledAndContinue()
} else {
updateStatus("Нет BLE-разрешений")
appendLog("Permissions denied by user")
Toast.makeText(this, "Разрешения отклонены", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "Permissions denied", Toast.LENGTH_SHORT).show()
}
}
private val enableBluetoothLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (bluetoothAdapter()?.isEnabled == true) {
continueAfterBluetoothReady()
} else {
updateStatus("Bluetooth is disabled")
appendLog("Bluetooth enable request denied")
Toast.makeText(this, "Bluetooth is required", Toast.LENGTH_SHORT).show()
}
}
@@ -55,26 +116,72 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
statusText = findViewById(R.id.statusText)
peersText = findViewById(R.id.peersText)
logsText = findViewById(R.id.logsText)
activeChatTitle = findViewById(R.id.activeChatTitle)
targetInput = findViewById(R.id.targetInput)
messageInput = findViewById(R.id.messageInput)
chatListView = findViewById(R.id.chatListView)
messageListView = findViewById(R.id.messageListView)
chatsScreen = findViewById(R.id.chatsScreen)
settingsScreen = findViewById(R.id.settingsScreen)
autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch)
chatAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, chatItems)
messageAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, messageItems)
chatListView.adapter = chatAdapter
messageListView.adapter = messageAdapter
findViewById<Button>(R.id.btnTabChats).setOnClickListener { showChats() }
findViewById<Button>(R.id.btnTabSettings).setOnClickListener { showSettings() }
findViewById<Button>(R.id.btnStartMesh).setOnClickListener {
ensurePermissionsAndStart()
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
findViewById<Button>(R.id.btnStopMesh).setOnClickListener {
MeshForegroundService.stop(this)
updateStatus("Mesh остановлен")
updateStatus("Mesh stopped")
appendLog("Mesh service stop requested")
}
findViewById<Button>(R.id.btnSendMessage).setOnClickListener {
enqueueMessageFromUi()
}
findViewById<Button>(R.id.btnCheckUpdates).setOnClickListener {
checkForUpdates(manual = true)
}
chatListView.setOnItemClickListener { _, _, position, _ ->
val chat = chatSummaries[position]
activePeerId = chat.peerId
targetInput.setText(chat.peerId)
activeChatTitle.text = chat.peerId
refreshMessages()
}
autoUpdateSwitch.isChecked = prefs.getBoolean(KEY_AUTO_UPDATE, false)
autoUpdateSwitch.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(KEY_AUTO_UPDATE, isChecked).apply()
appendLog("Auto update set to $isChecked")
}
renderPeers()
renderLogs()
refreshChats()
refreshMessages()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
refreshChats()
refreshMessages()
if (autoUpdateSwitch.isChecked) {
checkForUpdates(manual = false)
}
}
override fun onStop() {
@@ -82,6 +189,16 @@ class MainActivity : AppCompatActivity() {
super.onStop()
}
private fun showChats() {
chatsScreen.visibility = android.view.View.VISIBLE
settingsScreen.visibility = android.view.View.GONE
}
private fun showSettings() {
chatsScreen.visibility = android.view.View.GONE
settingsScreen.visibility = android.view.View.VISIBLE
}
private fun registerMeshReceiver() {
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -92,17 +209,63 @@ class MainActivity : AppCompatActivity() {
}
}
private fun ensurePermissionsAndStart() {
private fun enqueueMessageFromUi() {
val targetId = targetInput.text.toString().trim()
val body = messageInput.text.toString().trim()
if (targetId.isEmpty() || body.isEmpty()) {
Toast.makeText(this, "Target and message are required", Toast.LENGTH_SHORT).show()
return
}
pendingSend = PendingSend(targetId, body)
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
appendLog("Message queued for $targetId")
messageInput.text?.clear()
}
private fun ensurePermissionsAndMaybeStart() {
val missing = requiredPermissions().filter { permission ->
ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
startMesh()
ensureBluetoothEnabledAndContinue()
} else {
permissionLauncher.launch(missing.toTypedArray())
}
}
private fun ensureBluetoothEnabledAndContinue() {
val adapter = bluetoothAdapter()
if (adapter == null) {
updateStatus("Bluetooth adapter unavailable")
appendLog("Bluetooth adapter unavailable")
return
}
if (adapter.isEnabled) {
continueAfterBluetoothReady()
} else {
val enableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
enableBluetoothLauncher.launch(enableIntent)
}
}
private fun continueAfterBluetoothReady() {
if (pendingStartRequested) {
startMesh()
pendingStartRequested = false
}
pendingSend?.let {
activePeerId = it.targetId
targetInput.setText(it.targetId)
activeChatTitle.text = it.targetId
MeshForegroundService.sendMessage(this, it.targetId, it.body)
pendingSend = null
}
refreshChats()
refreshMessages()
}
private fun requiredPermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -120,9 +283,151 @@ class MainActivity : AppCompatActivity() {
private fun startMesh() {
MeshForegroundService.start(this)
updateStatus("Запуск foreground service")
updateStatus("Foreground service starting")
appendLog("Mesh service start requested")
Toast.makeText(this, "Mesh запускается", Toast.LENGTH_SHORT).show()
}
private fun bluetoothAdapter(): BluetoothAdapter? {
val manager = getSystemService(BluetoothManager::class.java)
return manager?.adapter
}
private fun refreshChats() {
lifecycleScope.launch {
val chats = repository.chatSummaries()
chatSummaries.clear()
chatSummaries.addAll(chats)
chatItems.clear()
chatItems.addAll(
chats.map { chat ->
val time = timestampFormatter.format(Date(chat.lastTimestamp))
"${chat.peerId}\n${chat.lastBody}\n$time · ${chat.lastStatus}"
}
)
chatAdapter.notifyDataSetChanged()
if (activePeerId == null && chats.isNotEmpty()) {
activePeerId = chats.first().peerId
targetInput.setText(activePeerId)
activeChatTitle.text = activePeerId
}
}
}
private fun refreshMessages() {
val peerId = activePeerId ?: targetInput.text.toString().trim()
if (peerId.isEmpty()) {
messageItems.clear()
messageItems.add("Select a chat or enter a peer id")
messageAdapter.notifyDataSetChanged()
return
}
lifecycleScope.launch {
val messages = repository.messagesForPeer(peerId)
messageItems.clear()
if (messages.isEmpty()) {
messageItems.add("No messages with $peerId yet")
} else {
messageItems.addAll(
messages
.asReversed()
.map { message ->
val time = timestampFormatter.format(Date(message.createdAt))
val bubble = if (message.direction == MeshRepository.DIRECTION_OUTGOING) "You" else message.senderId
"[$time] $bubble\n${message.body}\n${message.status}"
}
)
}
messageAdapter.notifyDataSetChanged()
}
}
private fun checkForUpdates(manual: Boolean) {
lifecycleScope.launch {
val updateInfo = withContext(Dispatchers.IO) {
runCatching {
val connection = URL(UPDATE_METADATA_URL).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { reader ->
val json = JSONObject(reader.readText())
UpdateInfo(
versionCode = json.getInt("versionCode"),
versionName = json.getString("versionName"),
apkPath = json.getString("apkPath"),
releaseNotesTitle = json.optString("releaseNotesTitle", "Что нового"),
releaseNotesPath = json.optString("releaseNotesPath", "")
)
}
}.getOrNull()
}
if (updateInfo == null) {
if (manual) {
Toast.makeText(this@MainActivity, "Failed to check updates", Toast.LENGTH_SHORT).show()
}
appendLog("Update check failed")
return@launch
}
if (updateInfo.versionCode > currentVersionCode()) {
appendLog("Update found: ${updateInfo.versionName}")
showUpdateDialog(updateInfo)
} else if (manual) {
Toast.makeText(this@MainActivity, "You already have the latest version", Toast.LENGTH_SHORT).show()
}
}
}
private fun showUpdateDialog(updateInfo: UpdateInfo) {
lifecycleScope.launch {
val releaseNotes = withContext(Dispatchers.IO) {
fetchReleaseNotes(updateInfo.releaseNotesPath)
}
AlertDialog.Builder(this@MainActivity)
.setTitle(updateInfo.releaseNotesTitle)
.setMessage(
buildString {
append("Version ${updateInfo.versionName} is available.")
if (!releaseNotes.isNullOrBlank()) {
append("\n\n")
append(releaseNotes.trim())
}
}
)
.setPositiveButton("Open download") { _, _ ->
val url = if (updateInfo.apkPath.startsWith("http")) {
updateInfo.apkPath
} else {
"$UPDATE_BASE_URL/${updateInfo.apkPath.trimStart('/')}"
}
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
.setNegativeButton("Later", null)
.show()
}
}
private fun fetchReleaseNotes(path: String): String? {
if (path.isBlank()) return null
val url = if (path.startsWith("http")) path else "$UPDATE_BASE_URL/${path.trimStart('/')}"
return runCatching {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { it.readText() }
}.getOrNull()
}
private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
}
}
private fun updateStatus(text: String) {
@@ -145,21 +450,38 @@ class MainActivity : AppCompatActivity() {
private fun renderPeers() {
peersText.text = if (peers.isEmpty()) {
"Узлы не найдены"
"Nearby peers will appear here"
} else {
peers.joinToString(separator = "\n")
"Peers online: ${peers.joinToString(separator = ", ")}"
}
}
private fun renderLogs() {
logsText.text = if (logs.isEmpty()) {
"Лог пуст"
"Log is empty"
} else {
logs.joinToString(separator = "\n")
}
}
companion object {
private const val MAX_LOG_ENTRIES = 20
private const val KEY_AUTO_UPDATE = "auto_update"
private const val MAX_LOG_ENTRIES = 30
private const val UPDATE_BASE_URL = "https://net.nnn-team.pro"
private const val UPDATE_METADATA_URL = "$UPDATE_BASE_URL/assets/meta/version.json"
private val timestampFormatter = SimpleDateFormat("HH:mm", Locale.US)
}
private data class PendingSend(
val targetId: String,
val body: String
)
private data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val apkPath: String,
val releaseNotesTitle: String,
val releaseNotesPath: String
)
}

View File

@@ -0,0 +1,9 @@
package com.schoolmesh.messenger.data
data class ChatSummary(
val peerId: String,
val lastBody: String,
val lastStatus: String,
val lastTimestamp: Long,
val unreadCount: Int
)

View File

@@ -0,0 +1,31 @@
package com.schoolmesh.messenger.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [MessageEntity::class, OutboundQueueEntity::class],
version = 1,
exportSchema = false
)
abstract class MeshDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun outboundQueueDao(): OutboundQueueDao
companion object {
@Volatile
private var instance: MeshDatabase? = null
fun getInstance(context: Context): MeshDatabase {
return instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
MeshDatabase::class.java,
"mesh.db"
).build().also { instance = it }
}
}
}
}

View File

@@ -0,0 +1,113 @@
package com.schoolmesh.messenger.data
import com.schoolmesh.messenger.mesh.MeshPacket
import com.schoolmesh.messenger.mesh.PacketType
import java.util.UUID
class MeshRepository(
private val messageDao: MessageDao,
private val queueDao: OutboundQueueDao
) {
suspend fun enqueueOutgoingMessage(
senderId: String,
targetId: String,
body: String,
now: Long = System.currentTimeMillis()
): String {
val messageId = UUID.randomUUID().toString()
messageDao.upsert(
MessageEntity(
messageId = messageId,
senderId = senderId,
targetId = targetId,
body = body,
packetType = PacketType.MESSAGE.name,
direction = DIRECTION_OUTGOING,
status = STATUS_QUEUED,
createdAt = now,
updatedAt = now
)
)
queueDao.upsert(
OutboundQueueEntity(
messageId = messageId,
targetId = targetId,
body = body,
nextAttemptAt = now,
attemptCount = 0,
maxAttempts = DEFAULT_MAX_ATTEMPTS,
createdAt = now
)
)
return messageId
}
suspend fun recordIncomingMessage(packet: MeshPacket, now: Long = System.currentTimeMillis()) {
if (messageDao.findById(packet.messageId) != null) return
messageDao.upsert(
MessageEntity(
messageId = packet.messageId,
senderId = packet.senderId,
targetId = packet.targetId,
body = packet.payload,
packetType = packet.type.name,
direction = DIRECTION_INCOMING,
status = STATUS_DELIVERED,
createdAt = packet.timestamp,
updatedAt = now,
ackedAt = now
)
)
}
suspend fun markAckDelivered(originalMessageId: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(originalMessageId, STATUS_DELIVERED, now, now)
queueDao.delete(originalMessageId)
}
suspend fun markSendAttempt(
messageId: String,
attemptCount: Int,
nextAttemptAt: Long,
error: String? = null,
now: Long = System.currentTimeMillis()
) {
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
queueDao.updateAttempt(messageId, nextAttemptAt, attemptCount, error)
}
suspend fun markFailed(messageId: String, error: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(messageId, STATUS_FAILED, now, null)
queueDao.delete(messageId)
}
suspend fun readyQueue(now: Long = System.currentTimeMillis(), limit: Int = 20): List<OutboundQueueEntity> {
return queueDao.readyToSend(now, limit)
}
suspend fun recentMessages(limit: Int = 50): List<MessageEntity> {
return messageDao.recentMessages(limit)
}
suspend fun chatSummaries(): List<ChatSummary> {
return messageDao.chatSummaries()
}
suspend fun messagesForPeer(peerId: String, limit: Int = 100): List<MessageEntity> {
return messageDao.messagesForPeer(peerId, limit)
}
suspend fun queuedCount(): Int = queueDao.count()
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_SENT = "sent"
const val STATUS_DELIVERED = "delivered"
const val STATUS_FAILED = "failed"
const val DIRECTION_INCOMING = "incoming"
const val DIRECTION_OUTGOING = "outgoing"
private const val DEFAULT_MAX_ATTEMPTS = 5
}
}

View File

@@ -0,0 +1,62 @@
package com.schoolmesh.messenger.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface MessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: MessageEntity)
@Query("SELECT * FROM messages ORDER BY createdAt DESC LIMIT :limit")
suspend fun recentMessages(limit: Int): List<MessageEntity>
@Query("SELECT * FROM messages WHERE messageId = :messageId LIMIT 1")
suspend fun findById(messageId: String): MessageEntity?
@Query(
"""
SELECT * FROM messages
WHERE senderId = :peerId OR targetId = :peerId
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun messagesForPeer(peerId: String, limit: Int): List<MessageEntity>
@Query(
"""
SELECT
CASE
WHEN direction = 'incoming' THEN senderId
ELSE targetId
END AS peerId,
body AS lastBody,
status AS lastStatus,
createdAt AS lastTimestamp,
0 AS unreadCount
FROM messages
WHERE createdAt IN (
SELECT MAX(createdAt)
FROM messages
GROUP BY CASE
WHEN direction = 'incoming' THEN senderId
ELSE targetId
END
)
ORDER BY createdAt DESC
"""
)
suspend fun chatSummaries(): List<ChatSummary>
@Query(
"""
UPDATE messages
SET status = :status, updatedAt = :updatedAt, ackedAt = :ackedAt
WHERE messageId = :messageId
"""
)
suspend fun updateStatus(messageId: String, status: String, updatedAt: Long, ackedAt: Long?)
}

View File

@@ -0,0 +1,18 @@
package com.schoolmesh.messenger.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val messageId: String,
val senderId: String,
val targetId: String,
val body: String,
val packetType: String,
val direction: String,
val status: String,
val createdAt: Long,
val updatedAt: Long,
val ackedAt: Long? = null
)

View File

@@ -0,0 +1,37 @@
package com.schoolmesh.messenger.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface OutboundQueueDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(item: OutboundQueueEntity)
@Query(
"""
SELECT * FROM outbound_queue
WHERE nextAttemptAt <= :now AND attemptCount < maxAttempts
ORDER BY nextAttemptAt ASC
LIMIT :limit
"""
)
suspend fun readyToSend(now: Long, limit: Int): List<OutboundQueueEntity>
@Query("DELETE FROM outbound_queue WHERE messageId = :messageId")
suspend fun delete(messageId: String)
@Query(
"""
UPDATE outbound_queue
SET nextAttemptAt = :nextAttemptAt, attemptCount = :attemptCount, lastError = :lastError
WHERE messageId = :messageId
"""
)
suspend fun updateAttempt(messageId: String, nextAttemptAt: Long, attemptCount: Int, lastError: String?)
@Query("SELECT COUNT(*) FROM outbound_queue")
suspend fun count(): Int
}

View File

@@ -0,0 +1,16 @@
package com.schoolmesh.messenger.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "outbound_queue")
data class OutboundQueueEntity(
@PrimaryKey val messageId: String,
val targetId: String,
val body: String,
val nextAttemptAt: Long,
val attemptCount: Int,
val maxAttempts: Int,
val createdAt: Long,
val lastError: String? = null
)

View File

@@ -21,6 +21,7 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
@@ -35,6 +36,8 @@ class BleMeshManager(
private val context: Context,
private val onPeerDiscovered: (String) -> Unit = {},
private val onStatusChanged: (String) -> Unit = {},
private val onAckReceived: (String) -> Unit = {},
private val onMessageReceived: (MeshPacket) -> Unit = {},
private val onError: (String) -> Unit = {},
private val onLog: (String) -> Unit = {},
private val seenPacketCache: SeenPacketCache = SeenPacketCache()
@@ -55,6 +58,9 @@ class BleMeshManager(
bluetoothAdapter?.address ?: "android-${UUID.randomUUID()}"
}
val nodeId: String
get() = localNodeId
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device ?: return
@@ -172,7 +178,7 @@ class BleMeshManager(
}
return when (packet.type) {
PacketType.ACK -> MeshAction.ConsumeAck(packet.messageId)
PacketType.ACK -> MeshAction.ConsumeAck(packet.payload)
PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId)
PacketType.MESSAGE -> MeshAction.ProcessAndRelay(packet.decrementedTtl())
}
@@ -227,7 +233,10 @@ class BleMeshManager(
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.ConsumeAck -> {
onAckReceived(action.messageId)
log("ACK consumed: ${action.messageId}")
}
is MeshAction.ConsumePresence -> {
onPeerDiscovered(action.senderId)
onStatusChanged("Presence from ${action.senderId}")
@@ -235,6 +244,7 @@ class BleMeshManager(
}
is MeshAction.ProcessAndRelay -> {
onMessageReceived(packet)
onStatusChanged("Message from ${packet.senderId}")
log("Relaying packet ${packet.messageId}")
broadcastPacket(action.packetToRelay)
@@ -340,31 +350,42 @@ class BleMeshManager(
broadcastPacket(ack)
}
private fun broadcastPacket(packet: MeshPacket) {
private fun broadcastPacket(packet: MeshPacket): Boolean {
var sent = false
activeConnections.values.forEach { gatt ->
writePacket(gatt, packet)
sent = writePacket(gatt, packet) || sent
}
return sent
}
fun sendPacket(packet: MeshPacket): Boolean {
val directedGatt = activeConnections[packet.targetId]
return if (directedGatt != null) {
writePacket(directedGatt, packet)
} else {
broadcastPacket(packet)
}
}
@SuppressLint("MissingPermission")
private fun writePacket(gatt: BluetoothGatt, packet: MeshPacket) {
private fun writePacket(gatt: BluetoothGatt, packet: MeshPacket): Boolean {
val characteristic = gatt
.getService(MESH_SERVICE_UUID)
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
if (characteristic == null) {
log("Remote characteristic missing on ${gatt.device.address}")
return
return false
}
val payload = MeshPacketCodec.encode(packet).toByteArray(StandardCharsets.UTF_8)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(
characteristic,
payload,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT

View File

@@ -10,23 +10,50 @@ import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.schoolmesh.messenger.R
import com.schoolmesh.messenger.data.MeshDatabase
import com.schoolmesh.messenger.data.MeshRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class MeshForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var bleMeshManager: BleMeshManager
private lateinit var repository: MeshRepository
private lateinit var queueProcessor: MeshQueueProcessor
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
bleMeshManager = BleMeshManager(
context = applicationContext,
onPeerDiscovered = { address ->
sendEvent(MeshServiceContract.EVENT_PEER, address)
sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address")
queueProcessor.poke()
},
onStatusChanged = { status ->
sendEvent(MeshServiceContract.EVENT_STATUS, status)
updateNotification(status)
},
onAckReceived = { messageId ->
serviceScope.launch {
repository.markAckDelivered(messageId)
sendEvent(MeshServiceContract.EVENT_LOG, "ACK delivered for $messageId")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
}
},
onMessageReceived = { packet ->
serviceScope.launch {
repository.recordIncomingMessage(packet)
sendEvent(MeshServiceContract.EVENT_LOG, "Message stored from ${packet.senderId}")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
}
},
onError = { message ->
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message")
@@ -36,18 +63,27 @@ class MeshForegroundService : Service() {
sendEvent(MeshServiceContract.EVENT_LOG, message)
}
)
queueProcessor = MeshQueueProcessor(
scope = serviceScope,
repository = repository,
meshManager = bleMeshManager,
onLog = { message -> sendEvent(MeshServiceContract.EVENT_LOG, message) }
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
MeshServiceContract.ACTION_STOP -> stopMesh()
MeshServiceContract.ACTION_SEND_MESSAGE -> enqueueMessage(intent)
else -> startMesh()
}
return START_STICKY
}
override fun onDestroy() {
queueProcessor.stop()
bleMeshManager.stop()
serviceScope.cancel()
super.onDestroy()
}
@@ -56,14 +92,37 @@ class MeshForegroundService : Service() {
private fun startMesh() {
startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается"))
bleMeshManager.start()
queueProcessor.start()
queueProcessor.poke()
}
private fun stopMesh() {
queueProcessor.stop()
bleMeshManager.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun enqueueMessage(intent: Intent) {
val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty()
val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty()
if (targetId.isEmpty() || messageBody.isEmpty()) {
sendEvent(MeshServiceContract.EVENT_LOG, "Cannot enqueue empty target/body")
return
}
serviceScope.launch {
val messageId = repository.enqueueOutgoingMessage(
senderId = bleMeshManager.nodeId,
targetId = targetId,
body = messageBody
)
sendEvent(MeshServiceContract.EVENT_LOG, "Message queued: $messageId")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
queueProcessor.poke()
}
}
private fun buildNotification(contentText: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
@@ -115,5 +174,14 @@ class MeshForegroundService : Service() {
}
context.startService(intent)
}
fun sendMessage(context: Context, targetId: String, body: String) {
val intent = Intent(context, MeshForegroundService::class.java).apply {
action = MeshServiceContract.ACTION_SEND_MESSAGE
putExtra(MeshServiceContract.EXTRA_TARGET_ID, targetId)
putExtra(MeshServiceContract.EXTRA_MESSAGE_BODY, body)
}
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.schoolmesh.messenger.mesh
import com.schoolmesh.messenger.data.MeshRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class MeshQueueProcessor(
private val scope: CoroutineScope,
private val repository: MeshRepository,
private val meshManager: BleMeshManager,
private val onLog: (String) -> Unit = {}
) {
private var loopJob: Job? = null
fun start() {
if (loopJob?.isActive == true) return
loopJob = scope.launch {
while (isActive) {
processReadyQueue()
delay(POLL_INTERVAL_MS)
}
}
}
fun stop() {
loopJob?.cancel()
loopJob = null
}
fun poke() {
scope.launch {
processReadyQueue()
}
}
private suspend fun processReadyQueue() {
val items = repository.readyQueue()
items.forEach { item ->
val sent = meshManager.sendPacket(
MeshPacket(
messageId = item.messageId,
senderId = meshManager.nodeId,
targetId = item.targetId,
type = PacketType.MESSAGE,
payload = item.body
)
)
if (sent) {
val nextRetryAt = System.currentTimeMillis() + ACK_TIMEOUT_MS
repository.markSendAttempt(
messageId = item.messageId,
attemptCount = item.attemptCount + 1,
nextAttemptAt = nextRetryAt
)
onLog("Queued message ${item.messageId} sent, waiting for ACK")
} else if (item.attemptCount + 1 >= item.maxAttempts) {
repository.markFailed(item.messageId, "No reachable peers")
onLog("Queued message ${item.messageId} failed after max attempts")
} else {
val nextRetryAt = System.currentTimeMillis() + RETRY_BACKOFF_MS
repository.markSendAttempt(
messageId = item.messageId,
attemptCount = item.attemptCount + 1,
nextAttemptAt = nextRetryAt,
error = "No reachable peers"
)
onLog("Queued message ${item.messageId} rescheduled")
}
}
}
companion object {
private const val POLL_INTERVAL_MS = 5_000L
private const val ACK_TIMEOUT_MS = 8_000L
private const val RETRY_BACKOFF_MS = 4_000L
}
}

View File

@@ -3,12 +3,16 @@ 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_SEND_MESSAGE = "com.schoolmesh.messenger.mesh.SEND_MESSAGE"
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 EXTRA_TARGET_ID = "target_id"
const val EXTRA_MESSAGE_BODY = "message_body"
const val EVENT_STATUS = "status"
const val EVENT_PEER = "peer"
const val EVENT_LOG = "log"
const val EVENT_MESSAGES_CHANGED = "messages_changed"
}

View File

@@ -1,101 +1,242 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#18222D"
android:orientation="vertical"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
android:background="#233040"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="School Mesh Messenger"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Debug-экран BLE mesh: foreground service, найденные узлы и журнал событий." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnStartMesh"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Запустить mesh" />
<Button
android:id="@+id/btnStopMesh"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="Остановить mesh" />
</LinearLayout>
<TextView
android:id="@+id/statusLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Статус"
android:layout_weight="1"
android:text="NNNet"
android:textColor="#F3F7FB"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Ожидание запуска" />
<TextView
android:id="@+id/peersLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Найденные узлы"
android:textStyle="bold" />
<TextView
android:id="@+id/peersText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#EAF3F7"
android:padding="12dp"
android:text="Узлы не найдены" />
<TextView
android:id="@+id/logsLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Журнал событий"
android:textStyle="bold" />
<TextView
android:id="@+id/logsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#101820"
android:padding="12dp"
android:text="Лог пуст"
android:textColor="#EAF7F2"
android:textIsSelectable="true" />
android:background="#2F6EA5"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:text="Offline"
android:textColor="#FFFFFF" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/btnTabChats"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="Chats" />
<Button
android:id="@+id/btnTabSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Settings" />
</LinearLayout>
<LinearLayout
android:id="@+id/chatsScreen"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#121A23"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:text="Chats"
android:textColor="#8FA1B3"
android:textStyle="bold" />
<ListView
android:id="@+id/chatListView"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#10161E"
android:divider="#1A2531"
android:dividerHeight="1dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#1F2C39"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/activeChatTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Select a chat"
android:textColor="#F3F7FB"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/peersText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Nearby peers will appear here"
android:textColor="#8FA1B3" />
</LinearLayout>
<ListView
android:id="@+id/messageListView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#0E1621"
android:divider="@android:color/transparent"
android:dividerHeight="8dp"
android:padding="12dp"
android:transcriptMode="alwaysScroll" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#18222D"
android:orientation="vertical"
android:padding="12dp">
<EditText
android:id="@+id/targetInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:hint="Peer ID"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#233040"
android:hint="Write a message"
android:minLines="2"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<Button
android:id="@+id/btnSendMessage"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:text="Send" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<ScrollView
android:id="@+id/settingsScreen"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings"
android:textColor="#F3F7FB"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Enable auto update checks"
android:textColor="#F3F7FB"
app:useMaterialThemeColors="false" />
<Button
android:id="@+id/btnCheckUpdates"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Check updates now" />
<Button
android:id="@+id/btnStartMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Start mesh" />
<Button
android:id="@+id/btnStopMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Stop mesh" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Event log"
android:textColor="#8FA1B3"
android:textStyle="bold" />
<TextView
android:id="@+id/logsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#101820"
android:padding="12dp"
android:text="Log is empty"
android:textColor="#EAF7F2"
android:textIsSelectable="true" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -1,5 +1,5 @@
<resources>
<string name="app_name">School Mesh Messenger</string>
<string name="notification_title">School Mesh Messenger</string>
<string name="app_name">NNNet</string>
<string name="notification_title">NNNet</string>
<string name="notification_channel_name">Mesh status</string>
</resources>