Add distributed user profiles and username directory

This commit is contained in:
dom4k
2026-03-17 02:25:07 +00:00
parent 1cfdb42e04
commit b4df94200e
19 changed files with 749 additions and 118 deletions

View File

@@ -13,6 +13,8 @@
- Реализован минимальный 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`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
- Публикация APK и сайта автоматизирована через `Makefile`. - Публикация APK и сайта автоматизирована через `Makefile`.
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
@@ -48,7 +50,7 @@
4. **Data Layer** 4. **Data Layer**
- локальное хранилище (Room); - локальное хранилище (Room);
- история сообщений и очередь исходящей доставки. - история сообщений, очередь исходящей доставки и каталог профилей.
5. **Security Layer** 5. **Security Layer**
- идентификация пользователя; - идентификация пользователя;
@@ -81,9 +83,10 @@
- [x] Добавить список чатов и базовый UI окна сообщений. - [x] Добавить список чатов и базовый UI окна сообщений.
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса. - [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
- [x] Подключить Room и базовую схему хранения. - [x] Подключить Room и базовую схему хранения.
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
- [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить логирование сети и debug-экран маршрутов.
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
- [ ] Реализовать базовую регистрацию пользователя (локальный профиль).
- [ ] Добавить шифрование полезной нагрузки сообщений. - [ ] Добавить шифрование полезной нагрузки сообщений.
- [ ] Написать инструментальные тесты BLE-обмена. - [ ] Написать инструментальные тесты BLE-обмена.
- [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap. - [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap.
@@ -108,10 +111,11 @@
Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE). Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE).
## Ближайший следующий шаг ## Ближайший следующий шаг
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами. Добавить шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
## Ограничения сети ## Ограничения сети
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами. - Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты. - Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android. - Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки. - Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.

View File

@@ -47,13 +47,9 @@ class ChatActivity : AppCompatActivity() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return
when (eventType) { when (eventType) {
MeshServiceContract.EVENT_STATUS -> subtitleText.text = value
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages() MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages()
MeshServiceContract.EVENT_PEER -> if (value == peerId) { MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshHeader()
subtitleText.text = getString(R.string.peer_nearby)
}
} }
} }
} }
@@ -89,7 +85,7 @@ class ChatActivity : AppCompatActivity() {
} }
val database = MeshDatabase.getInstance(applicationContext) val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
titleText = findViewById(R.id.chatTitleText) titleText = findViewById(R.id.chatTitleText)
subtitleText = findViewById(R.id.chatSubtitleText) subtitleText = findViewById(R.id.chatSubtitleText)
@@ -97,21 +93,20 @@ class ChatActivity : AppCompatActivity() {
emptyStateText = findViewById(R.id.emptyStateText) emptyStateText = findViewById(R.id.emptyStateText)
messagesListView = findViewById(R.id.messageListView) messagesListView = findViewById(R.id.messageListView)
titleText.text = peerId
subtitleText.text = getString(R.string.chat_waiting_status)
adapter = MessageListAdapter(this, messages) adapter = MessageListAdapter(this, messages)
messagesListView.adapter = adapter messagesListView.adapter = adapter
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() } findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() } findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() }
refreshHeader()
refreshMessages() refreshMessages()
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
registerMeshReceiver() registerMeshReceiver()
refreshHeader()
refreshMessages() refreshMessages()
} }
@@ -135,6 +130,14 @@ class ChatActivity : AppCompatActivity() {
receiverRegistered = true receiverRegistered = true
} }
private fun refreshHeader() {
lifecycleScope.launch {
val profile = repository.profileByPeerId(peerId)
titleText.text = profile?.displayName() ?: peerId
subtitleText.text = profile?.metaLine() ?: peerId
}
}
private fun sendMessage() { private fun sendMessage() {
val body = messageInput.text.toString().trim() val body = messageInput.text.toString().trim()
if (body.isEmpty()) { if (body.isEmpty()) {
@@ -177,7 +180,6 @@ class ChatActivity : AppCompatActivity() {
} }
val body = pendingBody ?: return val body = pendingBody ?: return
MeshForegroundService.sendMessage(this, peerId, body) MeshForegroundService.sendMessage(this, peerId, body)
subtitleText.text = getString(R.string.message_sending)
messageInput.text?.clear() messageInput.text?.clear()
pendingBody = null pendingBody = null
} }

View File

@@ -25,12 +25,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pro.nnnteam.nnnet.data.ChatSummary
import pro.nnnteam.nnnet.data.MeshDatabase import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.mesh.MeshForegroundService import pro.nnnteam.nnnet.mesh.MeshForegroundService
import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.ui.ChatListAdapter import pro.nnnteam.nnnet.ui.ChatListAdapter
import pro.nnnteam.nnnet.ui.ChatListItem
import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateManager import pro.nnnteam.nnnet.update.UpdateManager
import java.util.Locale import java.util.Locale
@@ -44,7 +44,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var chatListView: ListView private lateinit var chatListView: ListView
private val peers = linkedSetOf<String>() private val peers = linkedSetOf<String>()
private val chatSummaries = mutableListOf<ChatSummary>() private val chatItems = mutableListOf<ChatListItem>()
private lateinit var chatAdapter: ChatListAdapter private lateinit var chatAdapter: ChatListAdapter
private var receiverRegistered = false private var receiverRegistered = false
@@ -63,7 +63,8 @@ class MainActivity : AppCompatActivity() {
when (eventType) { when (eventType) {
MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value) MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value)
MeshServiceContract.EVENT_PEER -> addPeer(value) MeshServiceContract.EVENT_PEER -> addPeer(value)
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshChats() MeshServiceContract.EVENT_MESSAGES_CHANGED,
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshChats()
} }
} }
} }
@@ -71,8 +72,7 @@ class MainActivity : AppCompatActivity() {
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { result -> ) { result ->
val allGranted = result.values.all { it } if (result.values.all { it }) {
if (allGranted) {
ensureBluetoothEnabledAndContinue() ensureBluetoothEnabledAndContinue()
} else { } else {
Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show()
@@ -94,7 +94,7 @@ 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()) repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
deviceCountText = findViewById(R.id.deviceCountText) deviceCountText = findViewById(R.id.deviceCountText)
statusBadge = findViewById(R.id.statusBadge) statusBadge = findViewById(R.id.statusBadge)
@@ -102,10 +102,10 @@ class MainActivity : AppCompatActivity() {
emptyStateText = findViewById(R.id.emptyStateText) emptyStateText = findViewById(R.id.emptyStateText)
chatListView = findViewById(R.id.chatListView) chatListView = findViewById(R.id.chatListView)
chatAdapter = ChatListAdapter(this, chatSummaries) chatAdapter = ChatListAdapter(this, chatItems)
chatListView.adapter = chatAdapter chatListView.adapter = chatAdapter
chatListView.setOnItemClickListener { _, _, position, _ -> chatListView.setOnItemClickListener { _, _, position, _ ->
openChat(chatSummaries[position].peerId) openChat(chatItems[position].peerId)
} }
statusBadge.setOnClickListener { toggleMesh() } statusBadge.setOnClickListener { toggleMesh() }
@@ -164,7 +164,7 @@ class MainActivity : AppCompatActivity() {
private fun showNewChatDialog() { private fun showNewChatDialog() {
val input = EditText(this).apply { val input = EditText(this).apply {
hint = getString(R.string.hint_peer_id) hint = getString(R.string.hint_chat_target)
setSingleLine() setSingleLine()
setPadding(48, 32, 48, 32) setPadding(48, 32, 48, 32)
} }
@@ -172,18 +172,44 @@ class MainActivity : AppCompatActivity() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.new_chat_title) .setTitle(R.string.new_chat_title)
.setView(input) .setView(input)
.setPositiveButton(R.string.open_chat) { _, _ -> .setPositiveButton(R.string.open_chat, null)
val peerId = input.text.toString().trim()
if (peerId.isNotEmpty()) {
openChat(peerId)
} else {
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.create()
.also { dialog ->
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val value = input.text.toString().trim()
if (value.isEmpty()) {
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
lifecycleScope.launch {
val resolvedPeerId = resolvePeerId(value)
if (resolvedPeerId != null) {
dialog.dismiss()
openChat(resolvedPeerId)
} else {
Toast.makeText(
this@MainActivity,
R.string.profile_not_found_locally,
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
.show() .show()
} }
private suspend fun resolvePeerId(value: String): String? {
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
return when {
':' in value -> value.trim()
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
}
}
private fun openChat(peerId: String) { private fun openChat(peerId: String) {
startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId)) startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId))
} }
@@ -238,10 +264,22 @@ class MainActivity : AppCompatActivity() {
private fun refreshChats() { private fun refreshChats() {
lifecycleScope.launch { lifecycleScope.launch {
val chats = repository.chatSummaries() val chats = repository.chatSummaries()
chatSummaries.clear() val mappedItems = chats.map { chat ->
chatSummaries.addAll(chats) val profile = repository.profileByPeerId(chat.peerId)
val title = profile?.displayName() ?: chat.peerId
val subtitlePrefix = profile?.let { "@${it.username} · " }.orEmpty()
ChatListItem(
peerId = chat.peerId,
title = title,
subtitle = subtitlePrefix + chat.lastBody,
lastStatus = chat.lastStatus,
lastTimestamp = chat.lastTimestamp
)
}
chatItems.clear()
chatItems.addAll(mappedItems)
chatAdapter.notifyDataSetChanged() chatAdapter.notifyDataSetChanged()
emptyStateText.visibility = if (chats.isEmpty()) View.VISIBLE else View.GONE emptyStateText.visibility = if (mappedItems.isEmpty()) View.VISIBLE else View.GONE
} }
} }
@@ -253,14 +291,14 @@ class MainActivity : AppCompatActivity() {
private fun updateMeshStatus(status: String) { private fun updateMeshStatus(status: String) {
val normalized = status.lowercase(Locale.getDefault()) val normalized = status.lowercase(Locale.getDefault())
if (normalized.contains("останов")) { if (normalized.contains("останов") || normalized.contains("оффлайн")) {
meshEnabled = false meshEnabled = false
peers.clear() peers.clear()
} else if ( } else if (
normalized.contains("актив") || normalized.contains("актив") ||
normalized.contains("запуска") || normalized.contains("запуска") ||
normalized.contains("в сети") || normalized.contains("в сети") ||
normalized.contains("присутствие") || normalized.contains("устройство") ||
normalized.contains("сообщение") normalized.contains("сообщение")
) { ) {
meshEnabled = true meshEnabled = true

View File

@@ -1,33 +1,78 @@
package pro.nnnteam.nnnet package pro.nnnteam.nnnet
import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.EditText
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfileEntity
import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateManager import pro.nnnteam.nnnet.update.UpdateManager
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var firstNameInput: EditText
private lateinit var lastNameInput: EditText
private lateinit var usernameInput: EditText
private lateinit var descriptionInput: EditText
private lateinit var searchInput: EditText
private lateinit var profileResultCard: android.view.View
private lateinit var resultNameText: TextView
private lateinit var resultUsernameText: TextView
private lateinit var resultDescriptionText: TextView
private lateinit var resultPeerIdText: TextView
private var receiverRegistered = false
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE) getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE)
} }
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
val query = searchInput.text.toString().trim()
if (query.isNotEmpty()) {
lookupProfile(query)
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() } findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
firstNameInput = findViewById(R.id.firstNameInput)
lastNameInput = findViewById(R.id.lastNameInput)
usernameInput = findViewById(R.id.usernameInput)
descriptionInput = findViewById(R.id.descriptionInput)
searchInput = findViewById(R.id.searchInput)
profileResultCard = findViewById(R.id.profileResultCard)
resultNameText = findViewById(R.id.resultNameText)
resultUsernameText = findViewById(R.id.resultUsernameText)
resultDescriptionText = findViewById(R.id.resultDescriptionText)
resultPeerIdText = findViewById(R.id.resultPeerIdText)
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)
autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false) autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)
@@ -41,14 +86,108 @@ class SettingsActivity : AppCompatActivity() {
currentVersionCode() currentVersionCode()
) )
findViewById<android.view.View>(R.id.checkUpdatesButton).setOnClickListener { findViewById<MaterialButton>(R.id.saveProfileButton).setOnClickListener { saveProfile() }
checkForUpdates() findViewById<MaterialButton>(R.id.searchButton).setOnClickListener {
val query = searchInput.text.toString().trim()
if (query.isEmpty()) {
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
} else {
lookupProfile(query)
}
}
findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() }
loadLocalProfile()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
}
override fun onStop() {
if (receiverRegistered) {
unregisterReceiver(meshEventReceiver)
receiverRegistered = false
}
super.onStop()
}
private fun registerMeshReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(meshEventReceiver, filter)
}
receiverRegistered = true
}
private fun loadLocalProfile() {
lifecycleScope.launch {
val localProfile = repository.localProfile()
if (localProfile != null) {
firstNameInput.setText(localProfile.firstName)
lastNameInput.setText(localProfile.lastName)
usernameInput.setText(localProfile.username)
descriptionInput.setText(localProfile.description)
}
}
}
private fun saveProfile() {
val firstName = firstNameInput.text.toString().trim()
val lastName = lastNameInput.text.toString().trim()
val username = usernameInput.text.toString().trim().removePrefix("@")
val description = descriptionInput.text.toString().trim()
if (username.isBlank()) {
Toast.makeText(this, R.string.username_required, Toast.LENGTH_SHORT).show()
return
}
lifecycleScope.launch {
repository.saveLocalProfile(
firstName = firstName,
lastName = lastName,
username = username,
description = description
)
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
}
}
private fun lookupProfile(username: String) {
lifecycleScope.launch {
val profile = repository.profileByUsername(username.removePrefix("@"))
renderSearchResult(profile)
if (profile == null) {
Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show()
}
}
}
private fun renderSearchResult(profile: ProfileEntity?) {
if (profile == null) {
profileResultCard.visibility = android.view.View.GONE
return
}
profileResultCard.visibility = android.view.View.VISIBLE
resultNameText.text = profile.displayName()
resultUsernameText.text = "@${profile.username}"
resultDescriptionText.text = profile.description.ifBlank { getString(R.string.no_profile_description) }
resultPeerIdText.text = if (profile.peerId.isBlank()) {
getString(R.string.peer_id_unknown)
} else {
getString(R.string.peer_id_value, profile.peerId)
} }
} }
private fun checkForUpdates() { private fun checkForUpdates() {
lifecycleScope.launch { lifecycleScope.launch {
val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
if (updateInfo == null) { if (updateInfo == null) {
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
@@ -63,10 +202,10 @@ class SettingsActivity : AppCompatActivity() {
private fun showUpdateDialog(updateInfo: UpdateInfo) { private fun showUpdateDialog(updateInfo: UpdateInfo) {
lifecycleScope.launch { lifecycleScope.launch {
val releaseNotes = withContext(Dispatchers.IO) { val releaseNotes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath) UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath)
} }
AlertDialog.Builder(this@SettingsActivity) androidx.appcompat.app.AlertDialog.Builder(this@SettingsActivity)
.setTitle(updateInfo.releaseNotesTitle) .setTitle(updateInfo.releaseNotesTitle)
.setMessage( .setMessage(
buildString { buildString {
@@ -79,7 +218,7 @@ class SettingsActivity : AppCompatActivity() {
) )
.setPositiveButton(R.string.download_update) { _, _ -> .setPositiveButton(R.string.download_update) { _, _ ->
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
} }
.setNegativeButton(R.string.later, null) .setNegativeButton(R.string.later, null)
.show() .show()

View File

@@ -6,13 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [MessageEntity::class, OutboundQueueEntity::class], entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class],
version = 1, version = 2,
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
companion object { companion object {
@Volatile @Volatile
@@ -24,7 +25,10 @@ abstract class MeshDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
MeshDatabase::class.java, MeshDatabase::class.java,
"mesh.db" "mesh.db"
).build().also { instance = it } )
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
} }
} }
} }

View File

@@ -6,7 +6,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
) { ) {
suspend fun enqueueOutgoingMessage( suspend fun enqueueOutgoingMessage(
senderId: String, senderId: String,
@@ -97,8 +98,92 @@ class MeshRepository(
return messageDao.messagesForPeer(peerId, limit) return messageDao.messagesForPeer(peerId, limit)
} }
suspend fun saveLocalProfile(
firstName: String,
lastName: String,
username: String,
description: String,
peerId: String = "",
now: Long = System.currentTimeMillis()
): ProfileEntity {
val normalizedUsername = normalizeUsername(username)
profileDao.deleteLocalProfiles()
val entity = ProfileEntity(
username = normalizedUsername,
firstName = firstName.trim(),
lastName = lastName.trim(),
description = description.trim(),
peerId = peerId,
updatedAt = now,
lastSeenAt = now,
isLocal = true
)
profileDao.upsert(entity)
return entity
}
suspend fun localProfile(): ProfileEntity? = profileDao.localProfile()
suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) {
if (peerId.isBlank()) return
profileDao.updateLocalPeerId(peerId, now, now)
}
suspend fun upsertRemoteProfile(
payload: ProfilePayload,
peerId: String,
now: Long = System.currentTimeMillis()
): ProfileEntity? {
val normalizedUsername = normalizeUsername(payload.username)
if (normalizedUsername.isBlank()) return null
val localProfile = profileDao.localProfile()
if (localProfile?.username == normalizedUsername) {
return localProfile
}
val existing = profileDao.findByUsername(normalizedUsername)
val entity = ProfileEntity(
username = normalizedUsername,
firstName = payload.firstName.trim(),
lastName = payload.lastName.trim(),
description = payload.description.trim(),
peerId = peerId,
updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L),
lastSeenAt = now,
isLocal = false
)
profileDao.upsert(entity)
return entity
}
suspend fun profileByUsername(username: String): ProfileEntity? {
return profileDao.findByUsername(normalizeUsername(username))
}
suspend fun profileByPeerId(peerId: String): ProfileEntity? {
if (peerId.isBlank()) return null
return profileDao.findByPeerId(peerId)
}
suspend fun searchProfiles(query: String, limit: Int = 20): List<ProfileEntity> {
val trimmed = query.trim()
if (trimmed.isBlank()) return emptyList()
val exact = profileByUsername(trimmed)
val fuzzy = profileDao.search("%$trimmed%", limit)
return buildList {
if (exact != null) add(exact)
fuzzy.forEach { candidate ->
if (none { it.username == candidate.username }) {
add(candidate)
}
}
}
}
suspend fun queuedCount(): Int = queueDao.count() suspend fun queuedCount(): Int = queueDao.count()
private fun normalizeUsername(value: String): String = value.trim().lowercase()
companion object { companion object {
const val STATUS_QUEUED = "queued" const val STATUS_QUEUED = "queued"
const val STATUS_SENT = "sent" const val STATUS_SENT = "sent"

View File

@@ -0,0 +1,37 @@
package pro.nnnteam.nnnet.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ProfileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(profile: ProfileEntity)
@Query("SELECT * FROM profiles WHERE isLocal = 1 LIMIT 1")
suspend fun localProfile(): ProfileEntity?
@Query("DELETE FROM profiles WHERE isLocal = 1")
suspend fun deleteLocalProfiles()
@Query("SELECT * FROM profiles WHERE username = :username LIMIT 1")
suspend fun findByUsername(username: String): ProfileEntity?
@Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
suspend fun findByPeerId(peerId: String): ProfileEntity?
@Query(
"""
SELECT * FROM profiles
WHERE username LIKE :query OR firstName LIKE :query OR lastName LIKE :query
ORDER BY isLocal DESC, updatedAt DESC
LIMIT :limit
"""
)
suspend fun search(query: String, limit: Int): List<ProfileEntity>
@Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1")
suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long)
}

View File

@@ -0,0 +1,32 @@
package pro.nnnteam.nnnet.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class ProfileEntity(
@PrimaryKey val username: String,
val firstName: String,
val lastName: String,
val description: String,
val peerId: String,
val updatedAt: Long,
val lastSeenAt: Long,
val isLocal: Boolean
) {
fun displayName(): String {
val fullName = listOf(firstName, lastName)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(" ")
return fullName.ifBlank { "@$username" }
}
fun metaLine(): String {
return if (peerId.isBlank()) {
"@$username"
} else {
"@$username · $peerId"
}
}
}

View File

@@ -0,0 +1,36 @@
package pro.nnnteam.nnnet.data
import org.json.JSONObject
data class ProfilePayload(
val firstName: String,
val lastName: String,
val username: String,
val description: String,
val updatedAt: Long
) {
fun normalizedUsername(): String = username.trim().lowercase()
}
object ProfilePayloadCodec {
fun encode(payload: ProfilePayload): String {
return JSONObject()
.put("firstName", payload.firstName)
.put("lastName", payload.lastName)
.put("username", payload.username)
.put("description", payload.description)
.put("updatedAt", payload.updatedAt)
.toString()
}
fun decode(raw: String): ProfilePayload {
val json = JSONObject(raw)
return ProfilePayload(
firstName = json.optString("firstName", "").trim(),
lastName = json.optString("lastName", "").trim(),
username = json.getString("username").trim(),
description = json.optString("description", "").trim(),
updatedAt = json.optLong("updatedAt", System.currentTimeMillis())
)
}
}

View File

@@ -7,11 +7,12 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothGattServer import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings import android.bluetooth.le.AdvertiseSettings
@@ -21,7 +22,6 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.bluetooth.BluetoothStatusCodes
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
@@ -38,6 +38,7 @@ class BleMeshManager(
private val onStatusChanged: (String) -> Unit = {}, private val onStatusChanged: (String) -> Unit = {},
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 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()
@@ -110,7 +111,7 @@ class BleMeshManager(
} }
val rawPacket = value.toString(StandardCharsets.UTF_8) val rawPacket = value.toString(StandardCharsets.UTF_8)
log("Packet received from ${device.address}: $rawPacket") log("Пакет получен от ${device.address}: $rawPacket")
handleIncomingPacket(rawPacket) handleIncomingPacket(rawPacket)
if (responseNeeded) { if (responseNeeded) {
@@ -125,20 +126,20 @@ class BleMeshManager(
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val address = device.address ?: return val address = device.address ?: return
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
log("GATT client error for $address: status=$status") log("Ошибка GATT-клиента для $address: status=$status")
closeConnection(address) closeConnection(address)
return return
} }
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
log("Connected to peer $address") log("Подключено к узлу $address")
activeConnections[address] = gatt activeConnections[address] = gatt
gatt.discoverServices() gatt.discoverServices()
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
log("Disconnected from peer $address") log("Узел отключился: $address")
closeConnection(address) closeConnection(address)
} }
} }
@@ -146,11 +147,12 @@ class BleMeshManager(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
log("Service discovery failed for ${device.address}: $status") log("Не удалось обнаружить сервисы у ${device.address}: $status")
return return
} }
log("Services discovered for ${device.address}") log("Сервисы обнаружены у ${device.address}")
sendPresence(gatt) sendPresence(gatt)
device.address?.let(onPeerDiscovered)
} }
override fun onCharacteristicWrite( override fun onCharacteristicWrite(
@@ -160,9 +162,9 @@ class BleMeshManager(
) { ) {
val address = device.address ?: return val address = device.address ?: return
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
log("Packet sent to $address") log("Пакет отправлен на $address")
} else { } else {
log("Packet send failed to $address: status=$status") log("Ошибка отправки пакета на $address: status=$status")
} }
} }
} }
@@ -178,9 +180,24 @@ class BleMeshManager(
} }
return when (packet.type) { return when (packet.type) {
PacketType.ACK -> MeshAction.ConsumeAck(packet.payload) PacketType.ACK -> {
if (packet.targetId == localNodeId) {
MeshAction.ConsumeAck(packet.payload)
} else {
MeshAction.Relay(packet.decrementedTtl())
}
}
PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId) PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId)
PacketType.MESSAGE -> MeshAction.ProcessAndRelay(packet.decrementedTtl()) PacketType.MESSAGE -> {
if (packet.targetId == localNodeId) {
MeshAction.DeliverMessage(packet)
} else {
MeshAction.Relay(packet.decrementedTtl())
}
}
PacketType.PROFILE -> MeshAction.CacheProfile(packet, packet.decrementedTtl())
} }
} }
@@ -242,14 +259,25 @@ class BleMeshManager(
onStatusChanged("Устройство ${action.senderId} рядом") onStatusChanged("Устройство ${action.senderId} рядом")
log("Сигнал присутствия обработан от ${action.senderId}") log("Сигнал присутствия обработан от ${action.senderId}")
} }
is MeshAction.DeliverMessage -> {
is MeshAction.ProcessAndRelay -> { onMessageReceived(action.packet)
onMessageReceived(packet) onStatusChanged("Новое сообщение от ${action.packet.senderId}")
onStatusChanged("Новое сообщение от ${packet.senderId}") sendAck(action.packet)
log("Ретрансляция пакета ${packet.messageId}")
broadcastPacket(action.packetToRelay)
sendAck(packet)
} }
is MeshAction.CacheProfile -> {
onProfileReceived(action.packet)
broadcastIfAlive(action.packetToRelay)
}
is MeshAction.Relay -> {
log("Ретрансляция пакета ${action.packetToRelay.messageId}")
broadcastIfAlive(action.packetToRelay)
}
}
}
private fun broadcastIfAlive(packet: MeshPacket) {
if (!packet.isExpired()) {
broadcastPacket(packet)
} }
} }
@@ -262,7 +290,7 @@ class BleMeshManager(
val server = manager.openGattServer(context, gattServerCallback) val server = manager.openGattServer(context, gattServerCallback)
if (server == null) { if (server == null) {
fail("Failed to open GATT server") fail("Не удалось открыть GATT server")
return return
} }
@@ -318,7 +346,7 @@ class BleMeshManager(
private fun connectToPeer(device: BluetoothDevice) { private fun connectToPeer(device: BluetoothDevice) {
val address = device.address ?: return val address = device.address ?: return
if (activeConnections.containsKey(address)) return if (activeConnections.containsKey(address)) return
log("Connecting to peer $address") log("Подключение к узлу $address")
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE) device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE)
} else { } else {
@@ -333,7 +361,7 @@ class BleMeshManager(
private fun sendPresence(gatt: BluetoothGatt) { private fun sendPresence(gatt: BluetoothGatt) {
val packet = MeshPacket( val packet = MeshPacket(
senderId = localNodeId, senderId = localNodeId,
targetId = gatt.device.address ?: "broadcast", targetId = gatt.device.address ?: BROADCAST_TARGET,
type = PacketType.PRESENCE, type = PacketType.PRESENCE,
payload = "presence:$localNodeId" payload = "presence:$localNodeId"
) )
@@ -374,7 +402,7 @@ class BleMeshManager(
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID) ?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
if (characteristic == null) { if (characteristic == null) {
log("Remote characteristic missing on ${gatt.device.address}") log("У удалённого узла нет mesh-характеристики: ${gatt.device.address}")
return false return false
} }
@@ -435,6 +463,7 @@ class BleMeshManager(
} }
companion object { companion object {
const val BROADCAST_TARGET = "*"
private const val TAG = "BleMeshManager" private const val TAG = "BleMeshManager"
private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b") private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b")
private val CHARACTERISTIC_PACKET_UUID: UUID = private val CHARACTERISTIC_PACKET_UUID: UUID =
@@ -447,5 +476,7 @@ sealed interface MeshAction {
data object DropExpired : MeshAction data object DropExpired : MeshAction
data class ConsumeAck(val messageId: String) : MeshAction data class ConsumeAck(val messageId: String) : MeshAction
data class ConsumePresence(val senderId: String) : MeshAction data class ConsumePresence(val senderId: String) : MeshAction
data class ProcessAndRelay(val packetToRelay: MeshPacket) : MeshAction data class DeliverMessage(val packet: MeshPacket) : MeshAction
data class CacheProfile(val packet: MeshPacket, val packetToRelay: MeshPacket) : MeshAction
data class Relay(val packetToRelay: MeshPacket) : MeshAction
} }

View File

@@ -9,32 +9,38 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfilePayload
import pro.nnnteam.nnnet.data.ProfilePayloadCodec
class MeshForegroundService : Service() { class MeshForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var bleMeshManager: BleMeshManager private lateinit var bleMeshManager: BleMeshManager
private lateinit var repository: MeshRepository private lateinit var repository: MeshRepository
private lateinit var queueProcessor: MeshQueueProcessor private lateinit var queueProcessor: MeshQueueProcessor
private var lastProfileBroadcastAt = 0L
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
val database = MeshDatabase.getInstance(applicationContext) val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
bleMeshManager = BleMeshManager( bleMeshManager = BleMeshManager(
context = applicationContext, context = applicationContext,
onPeerDiscovered = { address -> onPeerDiscovered = { address ->
sendEvent(MeshServiceContract.EVENT_PEER, address) sendEvent(MeshServiceContract.EVENT_PEER, address)
sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address") sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
queueProcessor.poke() queueProcessor.poke()
serviceScope.launch {
publishLocalProfile(force = false)
}
}, },
onStatusChanged = { status -> onStatusChanged = { status ->
sendEvent(MeshServiceContract.EVENT_STATUS, status) sendEvent(MeshServiceContract.EVENT_STATUS, status)
@@ -54,6 +60,16 @@ class MeshForegroundService : Service() {
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId) sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
} }
}, },
onProfileReceived = { packet ->
serviceScope.launch {
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
val stored = repository.upsertRemoteProfile(payload, packet.senderId)
if (stored != null) {
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}")
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.username)
}
}
},
onError = { message -> onError = { message ->
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
@@ -94,6 +110,41 @@ class MeshForegroundService : Service() {
bleMeshManager.start() bleMeshManager.start()
queueProcessor.start() queueProcessor.start()
queueProcessor.poke() queueProcessor.poke()
serviceScope.launch {
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
publishLocalProfile(force = true)
}
}
private suspend fun publishLocalProfile(force: Boolean) {
val now = System.currentTimeMillis()
if (!force && now - lastProfileBroadcastAt < PROFILE_BROADCAST_THROTTLE_MS) {
return
}
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
val localProfile = repository.localProfile() ?: return
val payload = ProfilePayload(
firstName = localProfile.firstName,
lastName = localProfile.lastName,
username = localProfile.username,
description = localProfile.description,
updatedAt = localProfile.updatedAt
)
val sent = bleMeshManager.sendPacket(
MeshPacket(
senderId = bleMeshManager.nodeId,
targetId = BleMeshManager.BROADCAST_TARGET,
type = PacketType.PROFILE,
payload = ProfilePayloadCodec.encode(payload)
)
)
if (sent) {
lastProfileBroadcastAt = now
}
if (sent || force) {
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, localProfile.username)
}
} }
private fun stopMesh() { private fun stopMesh() {
@@ -160,6 +211,7 @@ class MeshForegroundService : Service() {
companion object { companion object {
private const val CHANNEL_ID = "mesh_status" private const val CHANNEL_ID = "mesh_status"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val PROFILE_BROADCAST_THROTTLE_MS = 5_000L
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { val intent = Intent(context, MeshForegroundService::class.java).apply {

View File

@@ -15,4 +15,5 @@ object MeshServiceContract {
const val EVENT_PEER = "peer" const val EVENT_PEER = "peer"
const val EVENT_LOG = "log" const val EVENT_LOG = "log"
const val EVENT_MESSAGES_CHANGED = "messages_changed" const val EVENT_MESSAGES_CHANGED = "messages_changed"
const val EVENT_PROFILES_CHANGED = "profiles_changed"
} }

View File

@@ -3,5 +3,6 @@ package pro.nnnteam.nnnet.mesh
enum class PacketType { enum class PacketType {
MESSAGE, MESSAGE,
ACK, ACK,
PRESENCE PRESENCE,
PROFILE
} }

View File

@@ -7,21 +7,20 @@ import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import android.widget.TextView import android.widget.TextView
import pro.nnnteam.nnnet.R import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.ChatSummary
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
class ChatListAdapter( class ChatListAdapter(
context: Context, context: Context,
private val items: MutableList<ChatSummary> private val items: MutableList<ChatListItem>
) : BaseAdapter() { ) : BaseAdapter() {
private val inflater = LayoutInflater.from(context) private val inflater = LayoutInflater.from(context)
private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru")) private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru"))
override fun getCount(): Int = items.size override fun getCount(): Int = items.size
override fun getItem(position: Int): ChatSummary = items[position] override fun getItem(position: Int): ChatListItem = items[position]
override fun getItemId(position: Int): Long = position.toLong() override fun getItemId(position: Int): Long = position.toLong()
@@ -29,16 +28,16 @@ class ChatListAdapter(
val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false) val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false)
val item = getItem(position) val item = getItem(position)
view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.peerId) view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.title)
view.findViewById<TextView>(R.id.chatNameText).text = item.peerId view.findViewById<TextView>(R.id.chatNameText).text = item.title
view.findViewById<TextView>(R.id.chatPreviewText).text = item.lastBody view.findViewById<TextView>(R.id.chatPreviewText).text = item.subtitle
view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp)) view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp))
view.findViewById<TextView>(R.id.chatStatusText).text = statusLabel(item.lastStatus) view.findViewById<TextView>(R.id.chatStatusText).text = statusLabel(item.lastStatus)
return view return view
} }
private fun avatarLetter(peerId: String): String = peerId.firstOrNull()?.uppercase() ?: "N" private fun avatarLetter(title: String): String = title.firstOrNull()?.uppercase() ?: "N"
private fun statusLabel(status: String): String = when (status) { private fun statusLabel(status: String): String = when (status) {
"queued" -> "В очереди" "queued" -> "В очереди"

View File

@@ -0,0 +1,9 @@
package pro.nnnteam.nnnet.ui
data class ChatListItem(
val peerId: String,
val title: String,
val subtitle: String,
val lastStatus: String,
val lastTimestamp: Long
)

View File

@@ -35,16 +35,156 @@
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profile_section_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<EditText
android:id="@+id/firstNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/first_name"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/lastNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/last_name"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/usernameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/username"
android:inputType="textNoSuggestions"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/descriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:gravity="top"
android:hint="@string/profile_description"
android:minLines="3"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveProfileButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/save_profile"
app:cornerRadius="18dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/search_profile_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<EditText
android:id="@+id/searchInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/search_username"
android:inputType="textNoSuggestions"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/searchButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/find_profile"
app:cornerRadius="18dp" />
<LinearLayout
android:id="@+id/profileResultCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<TextView
android:id="@+id/resultNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/resultUsernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/accent_blue"
android:textSize="14sp" />
<TextView
android:id="@+id/resultDescriptionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
<TextView
android:id="@+id/resultPeerIdText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/primary_text"
android:textSize="13sp" />
</LinearLayout>
<TextView <TextView
android:id="@+id/versionText" android:id="@+id/versionText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/bg_settings_card" android:background="@drawable/bg_settings_card"
android:padding="16dp" android:padding="16dp"
android:textColor="@color/primary_text" android:textColor="@color/primary_text"
@@ -69,4 +209,5 @@
android:text="@string/check_updates" android:text="@string/check_updates"
app:cornerRadius="18dp" /> app:cornerRadius="18dp" />
</LinearLayout> </LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>

View File

@@ -12,12 +12,14 @@
<string name="new_chat">Новый чат</string> <string name="new_chat">Новый чат</string>
<string name="new_chat_title">Новый диалог</string> <string name="new_chat_title">Новый диалог</string>
<string name="hint_peer_id">Идентификатор устройства</string> <string name="hint_peer_id">Идентификатор устройства</string>
<string name="hint_chat_target">Username или peerId</string>
<string name="open_chat">Открыть чат</string> <string name="open_chat">Открыть чат</string>
<string name="cancel">Отмена</string> <string name="cancel">Отмена</string>
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string> <string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string> <string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string> <string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
<string name="peer_id_required">Введите ID устройства</string> <string name="peer_id_required">Введите ID устройства</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string> <string name="update_check_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>
@@ -34,4 +36,19 @@
<string name="auto_update">Автоматически проверять обновления</string> <string name="auto_update">Автоматически проверять обновления</string>
<string name="check_updates">Проверить обновления</string> <string name="check_updates">Проверить обновления</string>
<string name="current_version">Текущая версия: %1$s (%2$d)</string> <string name="current_version">Текущая версия: %1$s (%2$d)</string>
<string name="profile_section_title">Мой профиль</string>
<string name="first_name">Имя</string>
<string name="last_name">Фамилия</string>
<string name="username">Username</string>
<string name="profile_description">Описание</string>
<string name="save_profile">Сохранить профиль</string>
<string name="search_profile_title">Найти профиль</string>
<string name="search_username">Введите username</string>
<string name="find_profile">Найти</string>
<string name="enter_username_to_search">Введите username для поиска</string>
<string name="username_required">Username обязателен</string>
<string name="profile_saved">Профиль сохранён</string>
<string name="no_profile_description">Описание не указано</string>
<string name="peer_id_unknown">peerId пока неизвестен</string>
<string name="peer_id_value">peerId: %1$s</string>
</resources> </resources>

View File

@@ -2,11 +2,12 @@
## Слои ## Слои
- BLE Transport: сканирование, реклама, соединения, обмен пакетами. - BLE Transport: сканирование, реклама, соединения, обмен пакетами.
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK. - Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история. - Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
- Storage Layer: Room для локального хранения. - Storage 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`.
## Пользовательский сценарий ## Пользовательский сценарий
- Главный экран показывает список чатов в стиле Telegram. - Главный экран показывает список чатов в стиле Telegram.
@@ -14,11 +15,13 @@
- Слева в шапке показывается общее количество известных устройств в mesh. - Слева в шапке показывается общее количество известных устройств в mesh.
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. - Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога. - Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
## Топология сети ## Топология сети
- Выделенный сервер или хост для работы mesh не нужен. - Выделенный сервер или хост для работы mesh не нужен.
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. - Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. - Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
## Сетевой пакет (черновик) ## Сетевой пакет (черновик)
```json ```json
@@ -28,12 +31,12 @@
"targetId": "user-or-group-id", "targetId": "user-or-group-id",
"ttl": 6, "ttl": 6,
"timestamp": 0, "timestamp": 0,
"type": "message|ack|presence", "type": "message|ack|presence|profile",
"payload": "base64-or-json" "payload": "base64-or-json"
} }
``` ```
## Ближайшие шаги ## Ближайшие шаги
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect. 1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
2. Ввести шифрование payload и управление профилями пользователей. 2. Ввести шифрование payload и подпись пакетов.
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон. 3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.

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>