From 909d1462f7b5d02857acfbad3c1663cd200e897e Mon Sep 17 00:00:00 2001 From: dom4k Date: Tue, 17 Mar 2026 02:33:33 +0000 Subject: [PATCH] Add network map and packet diagnostics --- README.md | 5 +- android/app/src/main/AndroidManifest.xml | 6 ++ .../java/pro/nnnteam/nnnet/ChatActivity.kt | 7 +- .../java/pro/nnnteam/nnnet/MainActivity.kt | 7 +- .../pro/nnnteam/nnnet/PacketLogActivity.kt | 60 +++++++++++++++ .../pro/nnnteam/nnnet/PacketMapActivity.kt | 67 +++++++++++++++++ .../pro/nnnteam/nnnet/SettingsActivity.kt | 13 +++- .../pro/nnnteam/nnnet/data/MeshDatabase.kt | 5 +- .../pro/nnnteam/nnnet/data/MeshRepository.kt | 68 ++++++++++++++++- .../pro/nnnteam/nnnet/data/PacketTraceDao.kt | 41 ++++++++++ .../nnnteam/nnnet/data/PacketTraceEntity.kt | 17 +++++ .../nnnteam/nnnet/data/PacketTraceSummary.kt | 7 ++ .../pro/nnnteam/nnnet/mesh/BleMeshManager.kt | 11 +++ .../nnnet/mesh/MeshForegroundService.kt | 22 +++++- .../pro/nnnteam/nnnet/ui/NetworkMapView.kt | 73 ++++++++++++++++++ .../nnnteam/nnnet/ui/PacketTraceAdapter.kt | 32 ++++++++ .../main/res/layout/activity_packet_log.xml | 75 +++++++++++++++++++ .../main/res/layout/activity_packet_map.xml | 50 +++++++++++++ .../src/main/res/layout/activity_settings.xml | 25 +++++++ .../src/main/res/layout/item_packet_trace.xml | 31 ++++++++ android/app/src/main/res/values/strings.xml | 8 ++ docs/ARCHITECTURE.md | 3 + website/index.html | 2 +- 23 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceDao.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceEntity.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceSummary.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/ui/NetworkMapView.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/ui/PacketTraceAdapter.kt create mode 100644 android/app/src/main/res/layout/activity_packet_log.xml create mode 100644 android/app/src/main/res/layout/activity_packet_map.xml create mode 100644 android/app/src/main/res/layout/item_packet_trace.xml diff --git a/README.md b/README.md index 647bec6..9e54e02 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. - Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. - Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. +- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - Публикация APK и сайта автоматизирована через `Makefile`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. @@ -50,7 +51,7 @@ 4. **Data Layer** - локальное хранилище (Room); - - история сообщений, очередь исходящей доставки и каталог профилей. + - история сообщений, очередь исходящей доставки, каталог профилей и журнал пакетов. 5. **Security Layer** - идентификация пользователя; @@ -85,6 +86,8 @@ - [x] Подключить Room и базовую схему хранения. - [x] Реализовать базовую регистрацию пользователя (локальный профиль). - [x] Добавить кэш профилей из mesh-сети и поиск по `username`. +- [x] Добавить журнал исходящих, входящих и транзитных пакетов. +- [x] Добавить режим карты сети в настройках. - [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. - [ ] Добавить шифрование полезной нагрузки сообщений. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3bdb227..33e2964 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,12 @@ android:roundIcon="@android:drawable/sym_def_app_icon" android:supportsRtl="true" android:theme="@style/Theme.NNNet"> + + diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt index bc354a3..512238c 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt @@ -85,7 +85,12 @@ class ChatActivity : AppCompatActivity() { } val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) titleText = findViewById(R.id.chatTitleText) subtitleText = findViewById(R.id.chatSubtitleText) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt index 844e425..dc73e74 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt @@ -94,7 +94,12 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) deviceCountText = findViewById(R.id.deviceCountText) statusBadge = findViewById(R.id.statusBadge) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt new file mode 100644 index 0000000..561bc75 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt @@ -0,0 +1,60 @@ +package pro.nnnteam.nnnet + +import android.os.Bundle +import android.widget.ImageButton +import android.widget.ListView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.launch +import pro.nnnteam.nnnet.data.MeshDatabase +import pro.nnnteam.nnnet.data.MeshRepository +import pro.nnnteam.nnnet.ui.PacketTraceAdapter + +class PacketLogActivity : AppCompatActivity() { + private lateinit var repository: MeshRepository + private lateinit var adapter: PacketTraceAdapter + private val items = mutableListOf() + private var currentDirection = MeshRepository.TRACE_OUTGOING + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_packet_log) + + val database = MeshDatabase.getInstance(applicationContext) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) + + findViewById(R.id.backButton).setOnClickListener { finish() } + adapter = PacketTraceAdapter(this, items) + findViewById(R.id.packetListView).adapter = adapter + + findViewById(R.id.outgoingButton).setOnClickListener { + currentDirection = MeshRepository.TRACE_OUTGOING + refresh() + } + findViewById(R.id.incomingButton).setOnClickListener { + currentDirection = MeshRepository.TRACE_INCOMING + refresh() + } + findViewById(R.id.relayButton).setOnClickListener { + currentDirection = MeshRepository.TRACE_RELAY + refresh() + } + + refresh() + } + + private fun refresh() { + lifecycleScope.launch { + val loaded = repository.recentPacketSummaries(currentDirection) + items.clear() + items.addAll(loaded) + adapter.notifyDataSetChanged() + } + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt new file mode 100644 index 0000000..b9ac51d --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt @@ -0,0 +1,67 @@ +package pro.nnnteam.nnnet + +import android.os.Bundle +import android.widget.ImageButton +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import pro.nnnteam.nnnet.data.MeshDatabase +import pro.nnnteam.nnnet.data.MeshRepository +import pro.nnnteam.nnnet.ui.MapNodeUi +import pro.nnnteam.nnnet.ui.NetworkMapView + +class PacketMapActivity : AppCompatActivity() { + private lateinit var repository: MeshRepository + private lateinit var mapView: NetworkMapView + private lateinit var mapMetaText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_packet_map) + + val database = MeshDatabase.getInstance(applicationContext) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) + + mapView = findViewById(R.id.mapView) + mapMetaText = findViewById(R.id.mapMetaText) + findViewById(R.id.backButton).setOnClickListener { finish() } + + renderMap() + } + + private fun renderMap() { + lifecycleScope.launch { + val local = repository.localProfile() + val remoteNodes = repository.mapNodes() + val nodes = buildList { + add( + MapNodeUi( + label = local?.displayName() ?: getString(R.string.you_label), + peerId = local?.peerId.orEmpty(), + isSelf = true + ) + ) + remoteNodes + .filter { it.peerId != local?.peerId } + .take(16) + .forEach { profile -> + add( + MapNodeUi( + label = profile.displayName(), + peerId = profile.peerId, + isSelf = false + ) + ) + } + } + mapView.submitNodes(nodes) + mapMetaText.text = getString(R.string.map_mode_hint, nodes.size) + } + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt index 8c48a6f..c0038d6 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt @@ -58,7 +58,12 @@ class SettingsActivity : AppCompatActivity() { setContentView(R.layout.activity_settings) val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) findViewById(R.id.backButton).setOnClickListener { finish() } @@ -95,6 +100,12 @@ class SettingsActivity : AppCompatActivity() { lookupProfile(query) } } + findViewById(R.id.openMapButton).setOnClickListener { + startActivity(Intent(this, PacketMapActivity::class.java)) + } + findViewById(R.id.openPacketLogButton).setOnClickListener { + startActivity(Intent(this, PacketLogActivity::class.java)) + } findViewById(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() } loadLocalProfile() diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt index 4b690dd..4ccfa80 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt @@ -6,14 +6,15 @@ import androidx.room.Room import androidx.room.RoomDatabase @Database( - entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class], - version = 2, + entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class], + version = 3, exportSchema = false ) abstract class MeshDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun outboundQueueDao(): OutboundQueueDao abstract fun profileDao(): ProfileDao + abstract fun packetTraceDao(): PacketTraceDao companion object { @Volatile diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt index d5a2e92..85a404c 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt @@ -7,7 +7,8 @@ import java.util.UUID class MeshRepository( private val messageDao: MessageDao, private val queueDao: OutboundQueueDao, - private val profileDao: ProfileDao + private val profileDao: ProfileDao, + private val packetTraceDao: PacketTraceDao ) { suspend fun enqueueOutgoingMessage( senderId: String, @@ -182,8 +183,70 @@ class MeshRepository( suspend fun queuedCount(): Int = queueDao.count() + suspend fun recordPacketTrace( + traceDirection: String, + packet: MeshPacket, + relatedPeerId: String = "", + now: Long = System.currentTimeMillis() + ) { + packetTraceDao.insert( + PacketTraceEntity( + traceDirection = traceDirection, + packetType = packet.type.name, + messageId = packet.messageId, + senderId = packet.senderId, + targetId = packet.targetId, + relatedPeerId = relatedPeerId, + payloadPreview = packet.payload.take(120), + createdAt = now + ) + ) + } + + suspend fun recentPacketTraces(direction: String, limit: Int = 50): List { + return packetTraceDao.recentByDirection(direction, limit) + } + + suspend fun recentPacketSummaries(direction: String, limit: Int = 50): List { + return packetTraceDao.recentByDirection(direction, limit).map { trace -> + PacketTraceSummary( + title = "${directionLabel(trace.traceDirection)} · ${trace.packetType}", + subtitle = "${trace.senderId} → ${trace.targetId}", + meta = buildString { + if (trace.relatedPeerId.isNotBlank()) { + append("через ${trace.relatedPeerId} · ") + } + append(trace.payloadPreview) + } + ) + } + } + + suspend fun mapNodes(limit: Int = 24): List { + val peerIds = packetTraceDao.recentPeerIds(limit) + return peerIds.mapNotNull { peerId -> + profileDao.findByPeerId(peerId) ?: ProfileEntity( + username = peerId.lowercase(), + firstName = "", + lastName = "", + description = "", + peerId = peerId, + updatedAt = 0L, + lastSeenAt = 0L, + isLocal = false + ) + } + } + private fun normalizeUsername(value: String): String = value.trim().lowercase() + private fun directionLabel(direction: String): String = when (direction) { + TRACE_OUTGOING -> "Исходящий" + TRACE_INCOMING -> "Входящий" + TRACE_RELAY -> "Транзит" + else -> direction + } + companion object { const val STATUS_QUEUED = "queued" const val STATUS_SENT = "sent" @@ -192,6 +255,9 @@ class MeshRepository( const val DIRECTION_INCOMING = "incoming" const val DIRECTION_OUTGOING = "outgoing" + const val TRACE_OUTGOING = "outgoing" + const val TRACE_INCOMING = "incoming" + const val TRACE_RELAY = "relay" private const val DEFAULT_MAX_ATTEMPTS = 5 } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceDao.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceDao.kt new file mode 100644 index 0000000..82bbd60 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceDao.kt @@ -0,0 +1,41 @@ +package pro.nnnteam.nnnet.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface PacketTraceDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(trace: PacketTraceEntity) + + @Query( + """ + SELECT * FROM packet_traces + WHERE traceDirection = :direction + ORDER BY createdAt DESC + LIMIT :limit + """ + ) + suspend fun recentByDirection(direction: String, limit: Int): List + + @Query( + """ + SELECT * FROM packet_traces + ORDER BY createdAt DESC + LIMIT :limit + """ + ) + suspend fun recent(limit: Int): List + + @Query( + """ + SELECT DISTINCT relatedPeerId FROM packet_traces + WHERE relatedPeerId != '' + ORDER BY createdAt DESC + LIMIT :limit + """ + ) + suspend fun recentPeerIds(limit: Int): List +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceEntity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceEntity.kt new file mode 100644 index 0000000..c15505e --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceEntity.kt @@ -0,0 +1,17 @@ +package pro.nnnteam.nnnet.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "packet_traces") +data class PacketTraceEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val traceDirection: String, + val packetType: String, + val messageId: String, + val senderId: String, + val targetId: String, + val relatedPeerId: String, + val payloadPreview: String, + val createdAt: Long +) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceSummary.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceSummary.kt new file mode 100644 index 0000000..a959216 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/PacketTraceSummary.kt @@ -0,0 +1,7 @@ +package pro.nnnteam.nnnet.data + +data class PacketTraceSummary( + val title: String, + val subtitle: String, + val meta: String +) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt index 232f0b8..dcae372 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt @@ -39,6 +39,9 @@ class BleMeshManager( private val onAckReceived: (String) -> Unit = {}, private val onMessageReceived: (MeshPacket) -> Unit = {}, private val onProfileReceived: (MeshPacket) -> Unit = {}, + private val onPacketOutgoing: (MeshPacket, String) -> Unit = { _, _ -> }, + private val onPacketIncoming: (MeshPacket, String) -> Unit = { _, _ -> }, + private val onPacketRelay: (MeshPacket, String) -> Unit = { _, _ -> }, private val onError: (String) -> Unit = {}, private val onLog: (String) -> Unit = {}, private val seenPacketCache: SeenPacketCache = SeenPacketCache() @@ -251,24 +254,29 @@ class BleMeshManager( MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}") MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}") is MeshAction.ConsumeAck -> { + onPacketIncoming(packet, packet.senderId) onAckReceived(action.messageId) log("ACK обработан: ${action.messageId}") } is MeshAction.ConsumePresence -> { + onPacketIncoming(packet, packet.senderId) onPeerDiscovered(action.senderId) onStatusChanged("Устройство ${action.senderId} рядом") log("Сигнал присутствия обработан от ${action.senderId}") } is MeshAction.DeliverMessage -> { + onPacketIncoming(action.packet, action.packet.senderId) onMessageReceived(action.packet) onStatusChanged("Новое сообщение от ${action.packet.senderId}") sendAck(action.packet) } is MeshAction.CacheProfile -> { + onPacketRelay(action.packet, action.packet.senderId) onProfileReceived(action.packet) broadcastIfAlive(action.packetToRelay) } is MeshAction.Relay -> { + onPacketRelay(action.packetToRelay, action.packetToRelay.senderId) log("Ретрансляция пакета ${action.packetToRelay.messageId}") broadcastIfAlive(action.packetToRelay) } @@ -365,6 +373,7 @@ class BleMeshManager( type = PacketType.PRESENCE, payload = "presence:$localNodeId" ) + onPacketOutgoing(packet, gatt.device.address ?: "") writePacket(gatt, packet) } @@ -375,6 +384,7 @@ class BleMeshManager( type = PacketType.ACK, payload = packet.messageId ) + onPacketOutgoing(ack, packet.senderId) broadcastPacket(ack) } @@ -387,6 +397,7 @@ class BleMeshManager( } fun sendPacket(packet: MeshPacket): Boolean { + onPacketOutgoing(packet, packet.targetId) val directedGatt = activeConnections[packet.targetId] return if (directedGatt != null) { writePacket(directedGatt, packet) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt index ab1f5f0..7d6dee4 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt @@ -31,7 +31,12 @@ class MeshForegroundService : Service() { super.onCreate() createNotificationChannel() val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) + repository = MeshRepository( + database.messageDao(), + database.outboundQueueDao(), + database.profileDao(), + database.packetTraceDao() + ) bleMeshManager = BleMeshManager( context = applicationContext, onPeerDiscovered = { address -> @@ -70,6 +75,21 @@ class MeshForegroundService : Service() { } } }, + onPacketOutgoing = { packet, relatedPeerId -> + serviceScope.launch { + repository.recordPacketTrace(MeshRepository.TRACE_OUTGOING, packet, relatedPeerId) + } + }, + onPacketIncoming = { packet, relatedPeerId -> + serviceScope.launch { + repository.recordPacketTrace(MeshRepository.TRACE_INCOMING, packet, relatedPeerId) + } + }, + onPacketRelay = { packet, relatedPeerId -> + serviceScope.launch { + repository.recordPacketTrace(MeshRepository.TRACE_RELAY, packet, relatedPeerId) + } + }, onError = { message -> sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message") diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/NetworkMapView.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/NetworkMapView.kt new file mode 100644 index 0000000..8fd7891 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/NetworkMapView.kt @@ -0,0 +1,73 @@ +package pro.nnnteam.nnnet.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +class NetworkMapView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#4C9EEB") + style = Paint.Style.FILL + } + private val selfPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#33A56E") + style = Paint.Style.FILL + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#1E2B37") + textSize = 34f + textAlign = Paint.Align.CENTER + } + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#A7B8C7") + strokeWidth = 4f + } + + private var nodes: List = emptyList() + + fun submitNodes(items: List) { + nodes = items + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (nodes.isEmpty()) return + + val cx = width / 2f + val cy = height / 2f + val radius = min(width, height) * 0.32f + val selfNode = nodes.first() + + canvas.drawCircle(cx, cy, 52f, selfPaint) + canvas.drawText(selfNode.label, cx, cy + 86f, textPaint) + + val others = nodes.drop(1) + if (others.isEmpty()) return + + others.forEachIndexed { index, node -> + val angle = (2 * PI * index / others.size) - PI / 2 + val nx = cx + (radius * cos(angle)).toFloat() + val ny = cy + (radius * sin(angle)).toFloat() + canvas.drawLine(cx, cy, nx, ny, linePaint) + canvas.drawCircle(nx, ny, 42f, nodePaint) + canvas.drawText(node.label, nx, ny + 76f, textPaint) + } + } +} + +data class MapNodeUi( + val label: String, + val peerId: String, + val isSelf: Boolean +) diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/PacketTraceAdapter.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/PacketTraceAdapter.kt new file mode 100644 index 0000000..304945d --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/PacketTraceAdapter.kt @@ -0,0 +1,32 @@ +package pro.nnnteam.nnnet.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView +import pro.nnnteam.nnnet.R +import pro.nnnteam.nnnet.data.PacketTraceSummary + +class PacketTraceAdapter( + context: Context, + private val items: MutableList +) : BaseAdapter() { + private val inflater = LayoutInflater.from(context) + + override fun getCount(): Int = items.size + + override fun getItem(position: Int): PacketTraceSummary = items[position] + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: inflater.inflate(R.layout.item_packet_trace, parent, false) + val item = getItem(position) + view.findViewById(R.id.traceTitleText).text = item.title + view.findViewById(R.id.traceSubtitleText).text = item.subtitle + view.findViewById(R.id.traceMetaText).text = item.meta + return view + } +} diff --git a/android/app/src/main/res/layout/activity_packet_log.xml b/android/app/src/main/res/layout/activity_packet_log.xml new file mode 100644 index 0000000..a7a2928 --- /dev/null +++ b/android/app/src/main/res/layout/activity_packet_log.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_packet_map.xml b/android/app/src/main/res/layout/activity_packet_map.xml new file mode 100644 index 0000000..be9e8d2 --- /dev/null +++ b/android/app/src/main/res/layout/activity_packet_map.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index bf721d0..ec8d97d 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -180,6 +180,31 @@ android:textSize="13sp" /> + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 99b44ee..67566de 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -51,4 +51,12 @@ Описание не указано peerId пока неизвестен peerId: %1$s + Диагностика сети + Режим карты + Журнал пакетов + Исходящие + Входящие + Транзит + Вы + Показано устройств: %1$d. Карта строится по сетевым связям и не является GPS-позицией. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1fb8b99..2817c0b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,6 +5,7 @@ - Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов. - Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история. - Storage Layer: Room для локального хранения сообщений, очереди и профилей. +- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`. - Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса. - Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента. - Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`. @@ -16,12 +17,14 @@ - Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. - Отправка сообщений доступна только из экрана конкретного диалога. - В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. +- В настройках доступны режим карты сети и экран журнала пакетов. ## Топология сети - Выделенный сервер или хост для работы mesh не нужен. - Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. - Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. - Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети. +- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется. ## Сетевой пакет (черновик) ```json diff --git a/website/index.html b/website/index.html index a3ea507..235194b 100644 --- a/website/index.html +++ b/website/index.html @@ -42,7 +42,7 @@
BLE-поиск

Обнаружение ближайших узлов и обмен пакетами без интернета.

Mesh-ретрансляция

Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.

-
Локальное хранение

Room хранит историю сообщений, очередь доставки и кэш профилей пользователей.

+
Локальное хранение

Room хранит историю сообщений, очередь доставки, кэш профилей и журнал пакетов.