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-пакетами.
- Есть 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.
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.

View File

@@ -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<ImageButton>(R.id.backButton).setOnClickListener { finish() }
findViewById<View>(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
}

View File

@@ -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<String>()
private val chatSummaries = mutableListOf<ChatSummary>()
private val chatItems = mutableListOf<ChatListItem>()
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

View File

@@ -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<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 versionText = findViewById<TextView>(R.id.versionText)
autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)
@@ -41,14 +86,108 @@ class SettingsActivity : AppCompatActivity() {
currentVersionCode()
)
findViewById<android.view.View>(R.id.checkUpdatesButton).setOnClickListener {
checkForUpdates()
findViewById<MaterialButton>(R.id.saveProfileButton).setOnClickListener { saveProfile() }
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() {
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()

View File

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

View File

@@ -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<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()
private fun normalizeUsername(value: String): String = value.trim().lowercase()
companion object {
const val STATUS_QUEUED = "queued"
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.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
}

View File

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

View File

@@ -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"
}

View File

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

View File

@@ -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<ChatSummary>
private val items: MutableList<ChatListItem>
) : 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<TextView>(R.id.avatarText).text = avatarLetter(item.peerId)
view.findViewById<TextView>(R.id.chatNameText).text = item.peerId
view.findViewById<TextView>(R.id.chatPreviewText).text = item.lastBody
view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.title)
view.findViewById<TextView>(R.id.chatNameText).text = item.title
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.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" -> "В очереди"

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,38 +35,179 @@
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:layout_height="match_parent">
<TextView
android:id="@+id/versionText"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textSize="15sp" />
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:text="@string/auto_update"
android:textColor="@color/primary_text"
app:useMaterialThemeColors="true" />
<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" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/check_updates"
app:cornerRadius="18dp" />
</LinearLayout>
<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
android:id="@+id/versionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textSize="15sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:text="@string/auto_update"
android:textColor="@color/primary_text"
app:useMaterialThemeColors="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/check_updates"
app:cornerRadius="18dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -12,12 +12,14 @@
<string name="new_chat">Новый чат</string>
<string name="new_chat_title">Новый диалог</string>
<string name="hint_peer_id">Идентификатор устройства</string>
<string name="hint_chat_target">Username или peerId</string>
<string name="open_chat">Открыть чат</string>
<string name="cancel">Отмена</string>
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
<string name="peer_id_required">Введите ID устройства</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</string>
@@ -34,4 +36,19 @@
<string name="auto_update">Автоматически проверять обновления</string>
<string name="check_updates">Проверить обновления</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>

View File

@@ -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-тесты на нескольких устройствах и полевой прогон.

View File

@@ -42,7 +42,7 @@
<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-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>
</section>