3 Commits

Author SHA1 Message Date
dom4k
da681cbd23 Release v0.1.4
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:47:58 +00:00
dom4k
c158fd63b6 Add in-app update installer flow and menu tools
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:43:13 +00:00
dom4k
909d1462f7 Add network map and packet diagnostics
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:33:33 +00:00
28 changed files with 787 additions and 18 deletions

View File

@@ -13,8 +13,11 @@
- Реализован минимальный GATT transport для обмена mesh-пакетами. - Реализован минимальный GATT transport для обмена mesh-пакетами.
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. - Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. - Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
- Публикация APK и сайта автоматизирована через `Makefile`. - Публикация APK и сайта автоматизирована через `Makefile`.
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
@@ -50,7 +53,7 @@
4. **Data Layer** 4. **Data Layer**
- локальное хранилище (Room); - локальное хранилище (Room);
- история сообщений, очередь исходящей доставки и каталог профилей. - история сообщений, очередь исходящей доставки, каталог профилей и журнал пакетов.
5. **Security Layer** 5. **Security Layer**
- идентификация пользователя; - идентификация пользователя;
@@ -85,6 +88,8 @@
- [x] Подключить Room и базовую схему хранения. - [x] Подключить Room и базовую схему хранения.
- [x] Реализовать базовую регистрацию пользователя (локальный профиль). - [x] Реализовать базовую регистрацию пользователя (локальный профиль).
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`. - [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
- [x] Добавить режим карты сети в настройках.
- [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить логирование сети и debug-экран маршрутов.
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
- [ ] Добавить шифрование полезной нагрузки сообщений. - [ ] Добавить шифрование полезной нагрузки сообщений.

View File

@@ -12,8 +12,8 @@ android {
applicationId = "pro.nnnteam.nnnet" applicationId = "pro.nnnteam.nnnet"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 4 versionCode = 5
versionName = "0.1.3" versionName = "0.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -25,6 +26,12 @@
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.NNNet"> android:theme="@style/Theme.NNNet">
<activity
android:name=".PacketLogActivity"
android:exported="false" />
<activity
android:name=".PacketMapActivity"
android:exported="false" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" /> android:exported="false" />
@@ -47,6 +54,16 @@
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="connectedDevice" /> android:foregroundServiceType="connectedDevice" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -85,7 +85,12 @@ class ChatActivity : AppCompatActivity() {
} }
val database = MeshDatabase.getInstance(applicationContext) 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) titleText = findViewById(R.id.chatTitleText)
subtitleText = findViewById(R.id.chatSubtitleText) subtitleText = findViewById(R.id.chatSubtitleText)

View File

@@ -94,7 +94,12 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
val database = MeshDatabase.getInstance(applicationContext) 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) deviceCountText = findViewById(R.id.deviceCountText)
statusBadge = findViewById(R.id.statusBadge) statusBadge = findViewById(R.id.statusBadge)
@@ -151,6 +156,14 @@ class MainActivity : AppCompatActivity() {
menuInflater.inflate(R.menu.main_menu, menu) menuInflater.inflate(R.menu.main_menu, menu)
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.menu_map -> {
startActivity(Intent(this@MainActivity, PacketMapActivity::class.java))
true
}
R.id.menu_packets -> {
startActivity(Intent(this@MainActivity, PacketLogActivity::class.java))
true
}
R.id.menu_settings -> { R.id.menu_settings -> {
startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
true true

View File

@@ -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<pro.nnnteam.nnnet.data.PacketTraceSummary>()
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<ImageButton>(R.id.backButton).setOnClickListener { finish() }
adapter = PacketTraceAdapter(this, items)
findViewById<ListView>(R.id.packetListView).adapter = adapter
findViewById<MaterialButton>(R.id.outgoingButton).setOnClickListener {
currentDirection = MeshRepository.TRACE_OUTGOING
refresh()
}
findViewById<MaterialButton>(R.id.incomingButton).setOnClickListener {
currentDirection = MeshRepository.TRACE_INCOMING
refresh()
}
findViewById<MaterialButton>(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()
}
}
}

View File

@@ -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<ImageButton>(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)
}
}
}

View File

@@ -2,6 +2,7 @@ package pro.nnnteam.nnnet
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
@@ -16,6 +17,7 @@ import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfileEntity import pro.nnnteam.nnnet.data.ProfileEntity
import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateInstaller
import pro.nnnteam.nnnet.update.UpdateManager import pro.nnnteam.nnnet.update.UpdateManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Intent import android.content.Intent
@@ -33,6 +35,7 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var resultUsernameText: TextView private lateinit var resultUsernameText: TextView
private lateinit var resultDescriptionText: TextView private lateinit var resultDescriptionText: TextView
private lateinit var resultPeerIdText: TextView private lateinit var resultPeerIdText: TextView
private lateinit var updateProgressText: TextView
private var receiverRegistered = false private var receiverRegistered = false
@@ -58,7 +61,12 @@ class SettingsActivity : AppCompatActivity() {
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
val database = MeshDatabase.getInstance(applicationContext) val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() } findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
@@ -72,6 +80,7 @@ class SettingsActivity : AppCompatActivity() {
resultUsernameText = findViewById(R.id.resultUsernameText) resultUsernameText = findViewById(R.id.resultUsernameText)
resultDescriptionText = findViewById(R.id.resultDescriptionText) resultDescriptionText = findViewById(R.id.resultDescriptionText)
resultPeerIdText = findViewById(R.id.resultPeerIdText) resultPeerIdText = findViewById(R.id.resultPeerIdText)
updateProgressText = findViewById(R.id.updateProgressText)
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch) val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(R.id.versionText) val versionText = findViewById<TextView>(R.id.versionText)
@@ -95,6 +104,12 @@ class SettingsActivity : AppCompatActivity() {
lookupProfile(query) lookupProfile(query)
} }
} }
findViewById<MaterialButton>(R.id.openMapButton).setOnClickListener {
startActivity(Intent(this, PacketMapActivity::class.java))
}
findViewById<MaterialButton>(R.id.openPacketLogButton).setOnClickListener {
startActivity(Intent(this, PacketLogActivity::class.java))
}
findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() } findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() }
loadLocalProfile() loadLocalProfile()
@@ -187,14 +202,17 @@ class SettingsActivity : AppCompatActivity() {
private fun checkForUpdates() { private fun checkForUpdates() {
lifecycleScope.launch { lifecycleScope.launch {
showUpdateProgress(getString(R.string.update_checking))
val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
if (updateInfo == null) { if (updateInfo == null) {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
return@launch return@launch
} }
if (updateInfo.versionCode > currentVersionCode()) { if (updateInfo.versionCode > currentVersionCode()) {
showUpdateDialog(updateInfo) showUpdateDialog(updateInfo)
} else { } else {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show() Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show()
} }
} }
@@ -217,14 +235,40 @@ class SettingsActivity : AppCompatActivity() {
} }
) )
.setPositiveButton(R.string.download_update) { _, _ -> .setPositiveButton(R.string.download_update) { _, _ ->
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) downloadAndInstallUpdate(updateInfo)
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
} }
.setNegativeButton(R.string.later, null) .setNegativeButton(R.string.later, null)
.show() .show()
} }
} }
private fun downloadAndInstallUpdate(updateInfo: UpdateInfo) {
lifecycleScope.launch {
showUpdateProgress(getString(R.string.update_downloading))
val apkFile = UpdateInstaller.downloadToTempFile(this@SettingsActivity, updateInfo)
if (apkFile == null) {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show()
return@launch
}
showUpdateProgress(getString(R.string.update_installing))
val started = UpdateInstaller.installDownloadedApk(this@SettingsActivity, apkFile)
if (!started) {
hideUpdateProgress()
}
}
}
private fun showUpdateProgress(message: String) {
updateProgressText.visibility = View.VISIBLE
updateProgressText.text = message
}
private fun hideUpdateProgress() {
updateProgressText.visibility = View.GONE
}
private fun currentVersionCode(): Int { private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0) val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -6,14 +6,15 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class], entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
abstract class MeshDatabase : RoomDatabase() { abstract class MeshDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao abstract fun messageDao(): MessageDao
abstract fun outboundQueueDao(): OutboundQueueDao abstract fun outboundQueueDao(): OutboundQueueDao
abstract fun profileDao(): ProfileDao abstract fun profileDao(): ProfileDao
abstract fun packetTraceDao(): PacketTraceDao
companion object { companion object {
@Volatile @Volatile

View File

@@ -7,7 +7,8 @@ import java.util.UUID
class MeshRepository( class MeshRepository(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val queueDao: OutboundQueueDao, private val queueDao: OutboundQueueDao,
private val profileDao: ProfileDao private val profileDao: ProfileDao,
private val packetTraceDao: PacketTraceDao
) { ) {
suspend fun enqueueOutgoingMessage( suspend fun enqueueOutgoingMessage(
senderId: String, senderId: String,
@@ -182,8 +183,70 @@ class MeshRepository(
suspend fun queuedCount(): Int = queueDao.count() 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<PacketTraceEntity> {
return packetTraceDao.recentByDirection(direction, limit)
}
suspend fun recentPacketSummaries(direction: String, limit: Int = 50): List<PacketTraceSummary> {
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<ProfileEntity> {
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 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 { companion object {
const val STATUS_QUEUED = "queued" const val STATUS_QUEUED = "queued"
const val STATUS_SENT = "sent" const val STATUS_SENT = "sent"
@@ -192,6 +255,9 @@ class MeshRepository(
const val DIRECTION_INCOMING = "incoming" const val DIRECTION_INCOMING = "incoming"
const val DIRECTION_OUTGOING = "outgoing" 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 private const val DEFAULT_MAX_ATTEMPTS = 5
} }

View File

@@ -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<PacketTraceEntity>
@Query(
"""
SELECT * FROM packet_traces
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun recent(limit: Int): List<PacketTraceEntity>
@Query(
"""
SELECT DISTINCT relatedPeerId FROM packet_traces
WHERE relatedPeerId != ''
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun recentPeerIds(limit: Int): List<String>
}

View File

@@ -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
)

View File

@@ -0,0 +1,7 @@
package pro.nnnteam.nnnet.data
data class PacketTraceSummary(
val title: String,
val subtitle: String,
val meta: String
)

View File

@@ -39,6 +39,9 @@ class BleMeshManager(
private val onAckReceived: (String) -> Unit = {}, private val onAckReceived: (String) -> Unit = {},
private val onMessageReceived: (MeshPacket) -> Unit = {}, private val onMessageReceived: (MeshPacket) -> Unit = {},
private val onProfileReceived: (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 onError: (String) -> Unit = {},
private val onLog: (String) -> Unit = {}, private val onLog: (String) -> Unit = {},
private val seenPacketCache: SeenPacketCache = SeenPacketCache() private val seenPacketCache: SeenPacketCache = SeenPacketCache()
@@ -251,24 +254,29 @@ class BleMeshManager(
MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}") MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}")
MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}") MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}")
is MeshAction.ConsumeAck -> { is MeshAction.ConsumeAck -> {
onPacketIncoming(packet, packet.senderId)
onAckReceived(action.messageId) onAckReceived(action.messageId)
log("ACK обработан: ${action.messageId}") log("ACK обработан: ${action.messageId}")
} }
is MeshAction.ConsumePresence -> { is MeshAction.ConsumePresence -> {
onPacketIncoming(packet, packet.senderId)
onPeerDiscovered(action.senderId) onPeerDiscovered(action.senderId)
onStatusChanged("Устройство ${action.senderId} рядом") onStatusChanged("Устройство ${action.senderId} рядом")
log("Сигнал присутствия обработан от ${action.senderId}") log("Сигнал присутствия обработан от ${action.senderId}")
} }
is MeshAction.DeliverMessage -> { is MeshAction.DeliverMessage -> {
onPacketIncoming(action.packet, action.packet.senderId)
onMessageReceived(action.packet) onMessageReceived(action.packet)
onStatusChanged("Новое сообщение от ${action.packet.senderId}") onStatusChanged("Новое сообщение от ${action.packet.senderId}")
sendAck(action.packet) sendAck(action.packet)
} }
is MeshAction.CacheProfile -> { is MeshAction.CacheProfile -> {
onPacketRelay(action.packet, action.packet.senderId)
onProfileReceived(action.packet) onProfileReceived(action.packet)
broadcastIfAlive(action.packetToRelay) broadcastIfAlive(action.packetToRelay)
} }
is MeshAction.Relay -> { is MeshAction.Relay -> {
onPacketRelay(action.packetToRelay, action.packetToRelay.senderId)
log("Ретрансляция пакета ${action.packetToRelay.messageId}") log("Ретрансляция пакета ${action.packetToRelay.messageId}")
broadcastIfAlive(action.packetToRelay) broadcastIfAlive(action.packetToRelay)
} }
@@ -365,6 +373,7 @@ class BleMeshManager(
type = PacketType.PRESENCE, type = PacketType.PRESENCE,
payload = "presence:$localNodeId" payload = "presence:$localNodeId"
) )
onPacketOutgoing(packet, gatt.device.address ?: "")
writePacket(gatt, packet) writePacket(gatt, packet)
} }
@@ -375,6 +384,7 @@ class BleMeshManager(
type = PacketType.ACK, type = PacketType.ACK,
payload = packet.messageId payload = packet.messageId
) )
onPacketOutgoing(ack, packet.senderId)
broadcastPacket(ack) broadcastPacket(ack)
} }
@@ -387,6 +397,7 @@ class BleMeshManager(
} }
fun sendPacket(packet: MeshPacket): Boolean { fun sendPacket(packet: MeshPacket): Boolean {
onPacketOutgoing(packet, packet.targetId)
val directedGatt = activeConnections[packet.targetId] val directedGatt = activeConnections[packet.targetId]
return if (directedGatt != null) { return if (directedGatt != null) {
writePacket(directedGatt, packet) writePacket(directedGatt, packet)

View File

@@ -31,7 +31,12 @@ class MeshForegroundService : Service() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
val database = MeshDatabase.getInstance(applicationContext) 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( bleMeshManager = BleMeshManager(
context = applicationContext, context = applicationContext,
onPeerDiscovered = { address -> 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 -> onError = { message ->
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")

View File

@@ -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<MapNodeUi> = emptyList()
fun submitNodes(items: List<MapNodeUi>) {
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
)

View File

@@ -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<PacketTraceSummary>
) : 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<TextView>(R.id.traceTitleText).text = item.title
view.findViewById<TextView>(R.id.traceSubtitleText).text = item.subtitle
view.findViewById<TextView>(R.id.traceMetaText).text = item.meta
return view
}
}

View File

@@ -0,0 +1,72 @@
package pro.nnnteam.nnnet.update
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.mesh.MeshForegroundService
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
object UpdateInstaller {
suspend fun downloadToTempFile(context: Context, updateInfo: UpdateInfo): File? {
return withContext(Dispatchers.IO) {
runCatching {
val updatesDir = File(context.cacheDir, "updates").apply { mkdirs() }
val apkFile = File(updatesDir, "nnnet-update-${updateInfo.versionName}.apk")
val connection = URL(UpdateManager.buildDownloadUrl(updateInfo.apkPath)).openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.inputStream.use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
apkFile
}.getOrNull()
}
}
fun installDownloadedApk(context: Context, apkFile: File, stopMesh: Boolean = true): Boolean {
if (!apkFile.exists()) return false
if (stopMesh) {
MeshForegroundService.stop(context)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Toast.makeText(context, R.string.allow_unknown_apps, Toast.LENGTH_LONG).show()
return false
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.installer_not_found, Toast.LENGTH_SHORT).show()
false
}
}
}

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/packet_log_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/outgoingButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/outgoing_packets" />
<com.google.android.material.button.MaterialButton
android:id="@+id/incomingButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/incoming_packets" />
<com.google.android.material.button.MaterialButton
android:id="@+id/relayButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/relay_packets" />
</LinearLayout>
<ListView
android:id="@+id/packetListView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:divider="@color/chat_divider"
android:dividerHeight="1dp"
android:listSelector="@android:color/transparent" />
</LinearLayout>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/map_mode_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/mapMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textColor="@color/secondary_text" />
<pro.nnnteam.nnnet.ui.NetworkMapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@@ -180,6 +180,31 @@
android:textSize="13sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/diagnostics_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openMapButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/map_mode_title"
app:cornerRadius="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openPacketLogButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/packet_log_title"
app:cornerRadius="18dp" />
<TextView <TextView
android:id="@+id/versionText" android:id="@+id/versionText"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -208,6 +233,14 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/check_updates" android:text="@string/check_updates"
app:cornerRadius="18dp" /> app:cornerRadius="18dp" />
<TextView
android:id="@+id/updateProgressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/traceTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/traceSubtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
<TextView
android:id="@+id/traceMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/accent_blue"
android:textSize="13sp" />
</LinearLayout>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_map"
android:title="@string/map_mode_title" />
<item
android:id="@+id/menu_packets"
android:title="@string/packet_log_title" />
<item <item
android:id="@+id/menu_settings" android:id="@+id/menu_settings"
android:title="@string/settings_title" /> android:title="@string/settings_title" />

View File

@@ -21,9 +21,15 @@
<string name="peer_id_required">Введите ID устройства</string> <string name="peer_id_required">Введите ID устройства</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string> <string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string> <string name="update_check_failed">Не удалось проверить обновления</string>
<string name="update_download_failed">Не удалось скачать обновление</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string> <string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</string> <string name="update_available_message">Доступна версия %1$s.</string>
<string name="download_update">Скачать обновление</string> <string name="download_update">Скачать обновление</string>
<string name="update_checking">Проверяем версию…</string>
<string name="update_downloading">Скачиваем APK во временный каталог…</string>
<string name="update_installing">Останавливаем сеть и запускаем установку…</string>
<string name="allow_unknown_apps">Разрешите установку из этого приложения, затем повторите обновление.</string>
<string name="installer_not_found">Системный установщик не найден</string>
<string name="later">Позже</string> <string name="later">Позже</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="no_messages">Сообщений пока нет. Напишите первым.</string> <string name="no_messages">Сообщений пока нет. Напишите первым.</string>
@@ -51,4 +57,12 @@
<string name="no_profile_description">Описание не указано</string> <string name="no_profile_description">Описание не указано</string>
<string name="peer_id_unknown">peerId пока неизвестен</string> <string name="peer_id_unknown">peerId пока неизвестен</string>
<string name="peer_id_value">peerId: %1$s</string> <string name="peer_id_value">peerId: %1$s</string>
<string name="diagnostics_title">Диагностика сети</string>
<string name="map_mode_title">Режим карты</string>
<string name="packet_log_title">Журнал пакетов</string>
<string name="outgoing_packets">Исходящие</string>
<string name="incoming_packets">Входящие</string>
<string name="relay_packets">Транзит</string>
<string name="you_label">Вы</string>
<string name="map_mode_hint">Показано устройств: %1$d. Карта строится по сетевым связям и не является GPS-позицией.</string>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="update_cache"
path="updates/" />
</paths>

View File

@@ -5,6 +5,7 @@
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов. - Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история. - Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
- Storage Layer: Room для локального хранения сообщений, очереди и профилей. - Storage Layer: Room для локального хранения сообщений, очереди и профилей.
- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`.
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса. - Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента. - Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`. - Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`.
@@ -13,15 +14,18 @@
- Главный экран показывает список чатов в стиле Telegram. - Главный экран показывает список чатов в стиле Telegram.
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`. - Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
- Слева в шапке показывается общее количество известных устройств в mesh. - Слева в шапке показывается общее количество известных устройств в mesh.
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. - В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога. - Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. - В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
- В настройках доступны режим карты сети и экран журнала пакетов.
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
## Топология сети ## Топология сети
- Выделенный сервер или хост для работы mesh не нужен. - Выделенный сервер или хост для работы mesh не нужен.
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. - Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. - Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети. - Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
## Сетевой пакет (черновик) ## Сетевой пакет (черновик)
```json ```json

View File

@@ -1,4 +1,3 @@
- Добавлены профили пользователей с именем, фамилией, username и описанием. - Добавлено меню: Карта сети, Пакеты и Настройки.
- Реализован распределённый каталог профилей: узлы кэшируют профили из mesh-сети и позволяют находить пользователя по username. - Добавлен встроенный поток обновления: проверка версии, скачивание APK во временный каталог, остановка mesh и запуск системной установки.
- Список чатов и экран диалога теперь показывают имя и username, если профиль уже известен. - Улучшена навигация по диагностическим инструментам сети.
- В настройках появился экран редактирования профиля и поиск профиля с получением peerId.

View File

@@ -42,7 +42,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div> <div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div>
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div> <div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div>
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений, очередь доставки и кэш профилей пользователей.</p></div></div> <div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений, очередь доставки, кэш профилей и журнал пакетов.</p></div></div>
</div> </div>
</div> </div>
</section> </section>