Add distributed user profiles and username directory
This commit is contained in:
10
README.md
10
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.
|
||||
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ package pro.nnnteam.nnnet.mesh
|
||||
enum class PacketType {
|
||||
MESSAGE,
|
||||
ACK,
|
||||
PRESENCE
|
||||
PRESENCE,
|
||||
PROFILE
|
||||
}
|
||||
|
||||
@@ -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" -> "В очереди"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-тесты на нескольких устройствах и полевой прогон.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user