From b4df94200edc88fc97d69779f1386bd38f21c70a Mon Sep 17 00:00:00 2001 From: dom4k Date: Tue, 17 Mar 2026 02:25:07 +0000 Subject: [PATCH] Add distributed user profiles and username directory --- README.md | 10 +- .../java/pro/nnnteam/nnnet/ChatActivity.kt | 22 +- .../java/pro/nnnteam/nnnet/MainActivity.kt | 80 +++++-- .../pro/nnnteam/nnnet/SettingsActivity.kt | 161 +++++++++++++- .../pro/nnnteam/nnnet/data/MeshDatabase.kt | 10 +- .../pro/nnnteam/nnnet/data/MeshRepository.kt | 87 +++++++- .../java/pro/nnnteam/nnnet/data/ProfileDao.kt | 37 ++++ .../pro/nnnteam/nnnet/data/ProfileEntity.kt | 32 +++ .../pro/nnnteam/nnnet/data/ProfilePayload.kt | 36 ++++ .../pro/nnnteam/nnnet/mesh/BleMeshManager.kt | 79 ++++--- .../nnnet/mesh/MeshForegroundService.kt | 60 +++++- .../nnnteam/nnnet/mesh/MeshServiceContract.kt | 1 + .../java/pro/nnnteam/nnnet/mesh/PacketType.kt | 3 +- .../pro/nnnteam/nnnet/ui/ChatListAdapter.kt | 13 +- .../java/pro/nnnteam/nnnet/ui/ChatListItem.kt | 9 + .../src/main/res/layout/activity_settings.xml | 197 +++++++++++++++--- android/app/src/main/res/values/strings.xml | 17 ++ docs/ARCHITECTURE.md | 11 +- website/index.html | 2 +- 19 files changed, 749 insertions(+), 118 deletions(-) create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt create mode 100644 android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListItem.kt diff --git a/README.md b/README.md index 48aec05..647bec6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ - Реализован минимальный GATT transport для обмена mesh-пакетами. - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. +- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. +- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - Публикация APK и сайта автоматизирована через `Makefile`. - Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`. @@ -48,7 +50,7 @@ 4. **Data Layer** - локальное хранилище (Room); - - история сообщений и очередь исходящей доставки. + - история сообщений, очередь исходящей доставки и каталог профилей. 5. **Security Layer** - идентификация пользователя; @@ -81,9 +83,10 @@ - [x] Добавить список чатов и базовый UI окна сообщений. - [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса. - [x] Подключить Room и базовую схему хранения. +- [x] Реализовать базовую регистрацию пользователя (локальный профиль). +- [x] Добавить кэш профилей из mesh-сети и поиск по `username`. - [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. -- [ ] Реализовать базовую регистрацию пользователя (локальный профиль). - [ ] Добавить шифрование полезной нагрузки сообщений. - [ ] Написать инструментальные тесты BLE-обмена. - [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap. @@ -108,10 +111,11 @@ Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE). ## Ближайший следующий шаг -Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами. +Добавить шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами. ## Ограничения сети - Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами. - Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты. - Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android. +- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh. - Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки. diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt index 9702571..bc354a3 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt @@ -47,13 +47,9 @@ class ChatActivity : AppCompatActivity() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != MeshServiceContract.ACTION_EVENT) return val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return - val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return when (eventType) { - MeshServiceContract.EVENT_STATUS -> subtitleText.text = value MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages() - MeshServiceContract.EVENT_PEER -> if (value == peerId) { - subtitleText.text = getString(R.string.peer_nearby) - } + MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshHeader() } } } @@ -89,7 +85,7 @@ class ChatActivity : AppCompatActivity() { } val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) titleText = findViewById(R.id.chatTitleText) subtitleText = findViewById(R.id.chatSubtitleText) @@ -97,21 +93,20 @@ class ChatActivity : AppCompatActivity() { emptyStateText = findViewById(R.id.emptyStateText) messagesListView = findViewById(R.id.messageListView) - titleText.text = peerId - subtitleText.text = getString(R.string.chat_waiting_status) - adapter = MessageListAdapter(this, messages) messagesListView.adapter = adapter findViewById(R.id.backButton).setOnClickListener { finish() } findViewById(R.id.sendButton).setOnClickListener { sendMessage() } + refreshHeader() refreshMessages() } override fun onStart() { super.onStart() registerMeshReceiver() + refreshHeader() refreshMessages() } @@ -135,6 +130,14 @@ class ChatActivity : AppCompatActivity() { 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() { val body = messageInput.text.toString().trim() if (body.isEmpty()) { @@ -177,7 +180,6 @@ class ChatActivity : AppCompatActivity() { } val body = pendingBody ?: return MeshForegroundService.sendMessage(this, peerId, body) - subtitleText.text = getString(R.string.message_sending) messageInput.text?.clear() pendingBody = null } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt index 466db7f..844e425 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt @@ -25,12 +25,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import pro.nnnteam.nnnet.data.ChatSummary import pro.nnnteam.nnnet.data.MeshDatabase import pro.nnnteam.nnnet.data.MeshRepository import pro.nnnteam.nnnet.mesh.MeshForegroundService import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.ui.ChatListAdapter +import pro.nnnteam.nnnet.ui.ChatListItem import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateManager import java.util.Locale @@ -44,7 +44,7 @@ class MainActivity : AppCompatActivity() { private lateinit var chatListView: ListView private val peers = linkedSetOf() - private val chatSummaries = mutableListOf() + private val chatItems = mutableListOf() private lateinit var chatAdapter: ChatListAdapter private var receiverRegistered = false @@ -63,7 +63,8 @@ class MainActivity : AppCompatActivity() { when (eventType) { MeshServiceContract.EVENT_STATUS -> updateMeshStatus(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( ActivityResultContracts.RequestMultiplePermissions() ) { result -> - val allGranted = result.values.all { it } - if (allGranted) { + if (result.values.all { it }) { ensureBluetoothEnabledAndContinue() } else { Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show() @@ -94,7 +94,7 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) deviceCountText = findViewById(R.id.deviceCountText) statusBadge = findViewById(R.id.statusBadge) @@ -102,10 +102,10 @@ class MainActivity : AppCompatActivity() { emptyStateText = findViewById(R.id.emptyStateText) chatListView = findViewById(R.id.chatListView) - chatAdapter = ChatListAdapter(this, chatSummaries) + chatAdapter = ChatListAdapter(this, chatItems) chatListView.adapter = chatAdapter chatListView.setOnItemClickListener { _, _, position, _ -> - openChat(chatSummaries[position].peerId) + openChat(chatItems[position].peerId) } statusBadge.setOnClickListener { toggleMesh() } @@ -164,7 +164,7 @@ class MainActivity : AppCompatActivity() { private fun showNewChatDialog() { val input = EditText(this).apply { - hint = getString(R.string.hint_peer_id) + hint = getString(R.string.hint_chat_target) setSingleLine() setPadding(48, 32, 48, 32) } @@ -172,18 +172,44 @@ class MainActivity : AppCompatActivity() { AlertDialog.Builder(this) .setTitle(R.string.new_chat_title) .setView(input) - .setPositiveButton(R.string.open_chat) { _, _ -> - val peerId = input.text.toString().trim() - if (peerId.isNotEmpty()) { - openChat(peerId) - } else { - Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show() + .setPositiveButton(R.string.open_chat, 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() + } + } + } } } - .setNegativeButton(R.string.cancel, null) .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) { startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId)) } @@ -238,10 +264,22 @@ class MainActivity : AppCompatActivity() { private fun refreshChats() { lifecycleScope.launch { val chats = repository.chatSummaries() - chatSummaries.clear() - chatSummaries.addAll(chats) + val mappedItems = chats.map { chat -> + 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() - 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) { val normalized = status.lowercase(Locale.getDefault()) - if (normalized.contains("останов")) { + if (normalized.contains("останов") || normalized.contains("оффлайн")) { meshEnabled = false peers.clear() } else if ( normalized.contains("актив") || normalized.contains("запуска") || normalized.contains("в сети") || - normalized.contains("присутствие") || + normalized.contains("устройство") || normalized.contains("сообщение") ) { meshEnabled = true diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt index d5eb135..8c48a6f 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt @@ -1,33 +1,78 @@ package pro.nnnteam.nnnet -import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle +import android.widget.EditText import android.widget.ImageButton import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope +import com.google.android.material.button.MaterialButton import com.google.android.material.switchmaterial.SwitchMaterial -import kotlinx.coroutines.Dispatchers 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.UpdateManager +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter 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 { 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?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) + val database = MeshDatabase.getInstance(applicationContext) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) + findViewById(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(R.id.autoUpdateSwitch) val versionText = findViewById(R.id.versionText) autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false) @@ -41,14 +86,108 @@ class SettingsActivity : AppCompatActivity() { currentVersionCode() ) - findViewById(R.id.checkUpdatesButton).setOnClickListener { - checkForUpdates() + findViewById(R.id.saveProfileButton).setOnClickListener { saveProfile() } + findViewById(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(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() { lifecycleScope.launch { - val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } + val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() } if (updateInfo == null) { Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show() return@launch @@ -63,10 +202,10 @@ class SettingsActivity : AppCompatActivity() { private fun showUpdateDialog(updateInfo: UpdateInfo) { lifecycleScope.launch { - val releaseNotes = withContext(Dispatchers.IO) { + val releaseNotes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath) } - AlertDialog.Builder(this@SettingsActivity) + androidx.appcompat.app.AlertDialog.Builder(this@SettingsActivity) .setTitle(updateInfo.releaseNotesTitle) .setMessage( buildString { @@ -79,7 +218,7 @@ class SettingsActivity : AppCompatActivity() { ) .setPositiveButton(R.string.download_update) { _, _ -> 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) .show() diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt index 6a6e984..4b690dd 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt @@ -6,13 +6,14 @@ import androidx.room.Room import androidx.room.RoomDatabase @Database( - entities = [MessageEntity::class, OutboundQueueEntity::class], - version = 1, + entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class], + version = 2, exportSchema = false ) abstract class MeshDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun outboundQueueDao(): OutboundQueueDao + abstract fun profileDao(): ProfileDao companion object { @Volatile @@ -24,7 +25,10 @@ abstract class MeshDatabase : RoomDatabase() { context.applicationContext, MeshDatabase::class.java, "mesh.db" - ).build().also { instance = it } + ) + .fallbackToDestructiveMigration() + .build() + .also { instance = it } } } } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt index a68efce..d5a2e92 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt @@ -6,7 +6,8 @@ import java.util.UUID class MeshRepository( private val messageDao: MessageDao, - private val queueDao: OutboundQueueDao + private val queueDao: OutboundQueueDao, + private val profileDao: ProfileDao ) { suspend fun enqueueOutgoingMessage( senderId: String, @@ -97,8 +98,92 @@ class MeshRepository( 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 { + 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() + private fun normalizeUsername(value: String): String = value.trim().lowercase() + companion object { const val STATUS_QUEUED = "queued" const val STATUS_SENT = "sent" diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt new file mode 100644 index 0000000..9db8e11 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt @@ -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 + + @Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1") + suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long) +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt new file mode 100644 index 0000000..b3cdb72 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt @@ -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" + } + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt new file mode 100644 index 0000000..9374f2f --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt @@ -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()) + ) + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt index f6e8d40..232f0b8 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/BleMeshManager.kt @@ -7,11 +7,12 @@ import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothGattServer import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseSettings @@ -21,7 +22,6 @@ import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings -import android.bluetooth.BluetoothStatusCodes import android.content.Context import android.content.pm.PackageManager import android.os.Build @@ -38,6 +38,7 @@ class BleMeshManager( private val onStatusChanged: (String) -> Unit = {}, private val onAckReceived: (String) -> Unit = {}, private val onMessageReceived: (MeshPacket) -> Unit = {}, + private val onProfileReceived: (MeshPacket) -> Unit = {}, private val onError: (String) -> Unit = {}, private val onLog: (String) -> Unit = {}, private val seenPacketCache: SeenPacketCache = SeenPacketCache() @@ -110,7 +111,7 @@ class BleMeshManager( } val rawPacket = value.toString(StandardCharsets.UTF_8) - log("Packet received from ${device.address}: $rawPacket") + log("Пакет получен от ${device.address}: $rawPacket") handleIncomingPacket(rawPacket) if (responseNeeded) { @@ -125,20 +126,20 @@ class BleMeshManager( override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { val address = device.address ?: return if (status != BluetoothGatt.GATT_SUCCESS) { - log("GATT client error for $address: status=$status") + log("Ошибка GATT-клиента для $address: status=$status") closeConnection(address) return } when (newState) { BluetoothProfile.STATE_CONNECTED -> { - log("Connected to peer $address") + log("Подключено к узлу $address") activeConnections[address] = gatt gatt.discoverServices() } BluetoothProfile.STATE_DISCONNECTED -> { - log("Disconnected from peer $address") + log("Узел отключился: $address") closeConnection(address) } } @@ -146,11 +147,12 @@ class BleMeshManager( override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { - log("Service discovery failed for ${device.address}: $status") + log("Не удалось обнаружить сервисы у ${device.address}: $status") return } - log("Services discovered for ${device.address}") + log("Сервисы обнаружены у ${device.address}") sendPresence(gatt) + device.address?.let(onPeerDiscovered) } override fun onCharacteristicWrite( @@ -160,9 +162,9 @@ class BleMeshManager( ) { val address = device.address ?: return if (status == BluetoothGatt.GATT_SUCCESS) { - log("Packet sent to $address") + log("Пакет отправлен на $address") } else { - log("Packet send failed to $address: status=$status") + log("Ошибка отправки пакета на $address: status=$status") } } } @@ -178,9 +180,24 @@ class BleMeshManager( } 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.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} рядом") log("Сигнал присутствия обработан от ${action.senderId}") } - - is MeshAction.ProcessAndRelay -> { - onMessageReceived(packet) - onStatusChanged("Новое сообщение от ${packet.senderId}") - log("Ретрансляция пакета ${packet.messageId}") - broadcastPacket(action.packetToRelay) - sendAck(packet) + is MeshAction.DeliverMessage -> { + onMessageReceived(action.packet) + onStatusChanged("Новое сообщение от ${action.packet.senderId}") + sendAck(action.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) if (server == null) { - fail("Failed to open GATT server") + fail("Не удалось открыть GATT server") return } @@ -318,7 +346,7 @@ class BleMeshManager( private fun connectToPeer(device: BluetoothDevice) { val address = device.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) { device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE) } else { @@ -333,7 +361,7 @@ class BleMeshManager( private fun sendPresence(gatt: BluetoothGatt) { val packet = MeshPacket( senderId = localNodeId, - targetId = gatt.device.address ?: "broadcast", + targetId = gatt.device.address ?: BROADCAST_TARGET, type = PacketType.PRESENCE, payload = "presence:$localNodeId" ) @@ -374,7 +402,7 @@ class BleMeshManager( ?.getCharacteristic(CHARACTERISTIC_PACKET_UUID) if (characteristic == null) { - log("Remote characteristic missing on ${gatt.device.address}") + log("У удалённого узла нет mesh-характеристики: ${gatt.device.address}") return false } @@ -435,6 +463,7 @@ class BleMeshManager( } companion object { + const val BROADCAST_TARGET = "*" private const val TAG = "BleMeshManager" private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b") private val CHARACTERISTIC_PACKET_UUID: UUID = @@ -447,5 +476,7 @@ sealed interface MeshAction { data object DropExpired : MeshAction data class ConsumeAck(val messageId: String) : MeshAction data class ConsumePresence(val senderId: String) : MeshAction - data class ProcessAndRelay(val packetToRelay: MeshPacket) : MeshAction + data class DeliverMessage(val packet: MeshPacket) : MeshAction + data class CacheProfile(val packet: MeshPacket, val packetToRelay: MeshPacket) : MeshAction + data class Relay(val packetToRelay: MeshPacket) : MeshAction } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt index c01b7e5..ab1f5f0 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt @@ -9,32 +9,38 @@ import android.content.Intent import android.os.Build import android.os.IBinder 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.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel 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() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var bleMeshManager: BleMeshManager private lateinit var repository: MeshRepository private lateinit var queueProcessor: MeshQueueProcessor + private var lastProfileBroadcastAt = 0L override fun onCreate() { super.onCreate() createNotificationChannel() val database = MeshDatabase.getInstance(applicationContext) - repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) + repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao()) bleMeshManager = BleMeshManager( context = applicationContext, onPeerDiscovered = { address -> sendEvent(MeshServiceContract.EVENT_PEER, address) sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address") queueProcessor.poke() + serviceScope.launch { + publishLocalProfile(force = false) + } }, onStatusChanged = { status -> sendEvent(MeshServiceContract.EVENT_STATUS, status) @@ -54,6 +60,16 @@ class MeshForegroundService : Service() { 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 -> sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message") @@ -94,6 +110,41 @@ class MeshForegroundService : Service() { bleMeshManager.start() queueProcessor.start() 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() { @@ -160,6 +211,7 @@ class MeshForegroundService : Service() { companion object { private const val CHANNEL_ID = "mesh_status" private const val NOTIFICATION_ID = 1001 + private const val PROFILE_BROADCAST_THROTTLE_MS = 5_000L fun start(context: Context) { val intent = Intent(context, MeshForegroundService::class.java).apply { diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt index 563f738..dc4c28e 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshServiceContract.kt @@ -15,4 +15,5 @@ object MeshServiceContract { const val EVENT_PEER = "peer" const val EVENT_LOG = "log" const val EVENT_MESSAGES_CHANGED = "messages_changed" + const val EVENT_PROFILES_CHANGED = "profiles_changed" } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt index 2fe21e2..8b8cbd3 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/PacketType.kt @@ -3,5 +3,6 @@ package pro.nnnteam.nnnet.mesh enum class PacketType { MESSAGE, ACK, - PRESENCE + PRESENCE, + PROFILE } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt index 279c1f7..a310ef9 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListAdapter.kt @@ -7,21 +7,20 @@ import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.TextView import pro.nnnteam.nnnet.R -import pro.nnnteam.nnnet.data.ChatSummary import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class ChatListAdapter( context: Context, - private val items: MutableList + private val items: MutableList ) : BaseAdapter() { private val inflater = LayoutInflater.from(context) private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru")) 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() @@ -29,16 +28,16 @@ class ChatListAdapter( val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false) val item = getItem(position) - view.findViewById(R.id.avatarText).text = avatarLetter(item.peerId) - view.findViewById(R.id.chatNameText).text = item.peerId - view.findViewById(R.id.chatPreviewText).text = item.lastBody + view.findViewById(R.id.avatarText).text = avatarLetter(item.title) + view.findViewById(R.id.chatNameText).text = item.title + view.findViewById(R.id.chatPreviewText).text = item.subtitle view.findViewById(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp)) view.findViewById(R.id.chatStatusText).text = statusLabel(item.lastStatus) 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) { "queued" -> "В очереди" diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListItem.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListItem.kt new file mode 100644 index 0000000..29cab93 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ui/ChatListItem.kt @@ -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 +) diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 22954ee..bf721d0 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -35,38 +35,179 @@ android:textStyle="bold" /> - + android:layout_height="match_parent"> - + android:orientation="vertical" + android:padding="16dp"> - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0c372ef..99b44ee 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -12,12 +12,14 @@ Новый чат Новый диалог Идентификатор устройства + Username или peerId Открыть чат Отмена Без разрешений BLE сеть не запустится Для работы нужен включённый Bluetooth Bluetooth на устройстве недоступен Введите ID устройства + Профиль не найден в локальном каталоге сети Не удалось проверить обновления У вас уже установлена последняя версия Доступна версия %1$s. @@ -34,4 +36,19 @@ Автоматически проверять обновления Проверить обновления Текущая версия: %1$s (%2$d) + Мой профиль + Имя + Фамилия + Username + Описание + Сохранить профиль + Найти профиль + Введите username + Найти + Введите username для поиска + Username обязателен + Профиль сохранён + Описание не указано + peerId пока неизвестен + peerId: %1$s diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 985a3c3..1fb8b99 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,11 +2,12 @@ ## Слои - BLE Transport: сканирование, реклама, соединения, обмен пакетами. -- Mesh Layer: маршрутизация, TTL, дедупликация, ACK. +- Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов. - Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история. -- Storage Layer: Room для локального хранения. +- Storage Layer: Room для локального хранения сообщений, очереди и профилей. - Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса. - Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента. +- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`. ## Пользовательский сценарий - Главный экран показывает список чатов в стиле Telegram. @@ -14,11 +15,13 @@ - Слева в шапке показывается общее количество известных устройств в mesh. - Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран. - Отправка сообщений доступна только из экрана конкретного диалога. +- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. ## Топология сети - Выделенный сервер или хост для работы mesh не нужен. - Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. - Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. +- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети. ## Сетевой пакет (черновик) ```json @@ -28,12 +31,12 @@ "targetId": "user-or-group-id", "ttl": 6, "timestamp": 0, - "type": "message|ack|presence", + "type": "message|ack|presence|profile", "payload": "base64-or-json" } ``` ## Ближайшие шаги 1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect. -2. Ввести шифрование payload и управление профилями пользователей. +2. Ввести шифрование payload и подпись пакетов. 3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон. diff --git a/website/index.html b/website/index.html index bd3f7d7..a3ea507 100644 --- a/website/index.html +++ b/website/index.html @@ -42,7 +42,7 @@
BLE-поиск

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

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

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

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

Room хранит историю сообщений и очередь исходящей доставки.

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

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