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-пакетами.
|
- Реализован минимальный GATT transport для обмена mesh-пакетами.
|
||||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
||||||
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||||
|
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
|
||||||
|
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
|
||||||
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
||||||
- Публикация APK и сайта автоматизирована через `Makefile`.
|
- Публикация APK и сайта автоматизирована через `Makefile`.
|
||||||
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
|
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
|
|
||||||
4. **Data Layer**
|
4. **Data Layer**
|
||||||
- локальное хранилище (Room);
|
- локальное хранилище (Room);
|
||||||
- история сообщений и очередь исходящей доставки.
|
- история сообщений, очередь исходящей доставки и каталог профилей.
|
||||||
|
|
||||||
5. **Security Layer**
|
5. **Security Layer**
|
||||||
- идентификация пользователя;
|
- идентификация пользователя;
|
||||||
@@ -81,9 +83,10 @@
|
|||||||
- [x] Добавить список чатов и базовый UI окна сообщений.
|
- [x] Добавить список чатов и базовый UI окна сообщений.
|
||||||
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
|
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
|
||||||
- [x] Подключить Room и базовую схему хранения.
|
- [x] Подключить Room и базовую схему хранения.
|
||||||
|
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
|
||||||
|
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
|
||||||
- [x] Добавить логирование сети и debug-экран маршрутов.
|
- [x] Добавить логирование сети и debug-экран маршрутов.
|
||||||
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
||||||
- [ ] Реализовать базовую регистрацию пользователя (локальный профиль).
|
|
||||||
- [ ] Добавить шифрование полезной нагрузки сообщений.
|
- [ ] Добавить шифрование полезной нагрузки сообщений.
|
||||||
- [ ] Написать инструментальные тесты BLE-обмена.
|
- [ ] Написать инструментальные тесты BLE-обмена.
|
||||||
- [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap.
|
- [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap.
|
||||||
@@ -108,10 +111,11 @@
|
|||||||
Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE).
|
Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE).
|
||||||
|
|
||||||
## Ближайший следующий шаг
|
## Ближайший следующий шаг
|
||||||
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
Добавить шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
||||||
|
|
||||||
## Ограничения сети
|
## Ограничения сети
|
||||||
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
|
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
|
||||||
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
||||||
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
||||||
|
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
|
||||||
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||||
|
|||||||
@@ -47,13 +47,9 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
|
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
|
||||||
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
|
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
|
||||||
val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return
|
|
||||||
when (eventType) {
|
when (eventType) {
|
||||||
MeshServiceContract.EVENT_STATUS -> subtitleText.text = value
|
|
||||||
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages()
|
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages()
|
||||||
MeshServiceContract.EVENT_PEER -> if (value == peerId) {
|
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshHeader()
|
||||||
subtitleText.text = getString(R.string.peer_nearby)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +85,7 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
|
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||||
|
|
||||||
titleText = findViewById(R.id.chatTitleText)
|
titleText = findViewById(R.id.chatTitleText)
|
||||||
subtitleText = findViewById(R.id.chatSubtitleText)
|
subtitleText = findViewById(R.id.chatSubtitleText)
|
||||||
@@ -97,21 +93,20 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
emptyStateText = findViewById(R.id.emptyStateText)
|
emptyStateText = findViewById(R.id.emptyStateText)
|
||||||
messagesListView = findViewById(R.id.messageListView)
|
messagesListView = findViewById(R.id.messageListView)
|
||||||
|
|
||||||
titleText.text = peerId
|
|
||||||
subtitleText.text = getString(R.string.chat_waiting_status)
|
|
||||||
|
|
||||||
adapter = MessageListAdapter(this, messages)
|
adapter = MessageListAdapter(this, messages)
|
||||||
messagesListView.adapter = adapter
|
messagesListView.adapter = adapter
|
||||||
|
|
||||||
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||||
findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() }
|
findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() }
|
||||||
|
|
||||||
|
refreshHeader()
|
||||||
refreshMessages()
|
refreshMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
registerMeshReceiver()
|
registerMeshReceiver()
|
||||||
|
refreshHeader()
|
||||||
refreshMessages()
|
refreshMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +130,14 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
receiverRegistered = true
|
receiverRegistered = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshHeader() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val profile = repository.profileByPeerId(peerId)
|
||||||
|
titleText.text = profile?.displayName() ?: peerId
|
||||||
|
subtitleText.text = profile?.metaLine() ?: peerId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendMessage() {
|
private fun sendMessage() {
|
||||||
val body = messageInput.text.toString().trim()
|
val body = messageInput.text.toString().trim()
|
||||||
if (body.isEmpty()) {
|
if (body.isEmpty()) {
|
||||||
@@ -177,7 +180,6 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
val body = pendingBody ?: return
|
val body = pendingBody ?: return
|
||||||
MeshForegroundService.sendMessage(this, peerId, body)
|
MeshForegroundService.sendMessage(this, peerId, body)
|
||||||
subtitleText.text = getString(R.string.message_sending)
|
|
||||||
messageInput.text?.clear()
|
messageInput.text?.clear()
|
||||||
pendingBody = null
|
pendingBody = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import pro.nnnteam.nnnet.data.ChatSummary
|
|
||||||
import pro.nnnteam.nnnet.data.MeshDatabase
|
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||||
import pro.nnnteam.nnnet.data.MeshRepository
|
import pro.nnnteam.nnnet.data.MeshRepository
|
||||||
import pro.nnnteam.nnnet.mesh.MeshForegroundService
|
import pro.nnnteam.nnnet.mesh.MeshForegroundService
|
||||||
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
||||||
import pro.nnnteam.nnnet.ui.ChatListAdapter
|
import pro.nnnteam.nnnet.ui.ChatListAdapter
|
||||||
|
import pro.nnnteam.nnnet.ui.ChatListItem
|
||||||
import pro.nnnteam.nnnet.update.UpdateInfo
|
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||||
import pro.nnnteam.nnnet.update.UpdateManager
|
import pro.nnnteam.nnnet.update.UpdateManager
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -44,7 +44,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var chatListView: ListView
|
private lateinit var chatListView: ListView
|
||||||
|
|
||||||
private val peers = linkedSetOf<String>()
|
private val peers = linkedSetOf<String>()
|
||||||
private val chatSummaries = mutableListOf<ChatSummary>()
|
private val chatItems = mutableListOf<ChatListItem>()
|
||||||
private lateinit var chatAdapter: ChatListAdapter
|
private lateinit var chatAdapter: ChatListAdapter
|
||||||
|
|
||||||
private var receiverRegistered = false
|
private var receiverRegistered = false
|
||||||
@@ -63,7 +63,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
when (eventType) {
|
when (eventType) {
|
||||||
MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value)
|
MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value)
|
||||||
MeshServiceContract.EVENT_PEER -> addPeer(value)
|
MeshServiceContract.EVENT_PEER -> addPeer(value)
|
||||||
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshChats()
|
MeshServiceContract.EVENT_MESSAGES_CHANGED,
|
||||||
|
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshChats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
) { result ->
|
) { result ->
|
||||||
val allGranted = result.values.all { it }
|
if (result.values.all { it }) {
|
||||||
if (allGranted) {
|
|
||||||
ensureBluetoothEnabledAndContinue()
|
ensureBluetoothEnabledAndContinue()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show()
|
||||||
@@ -94,7 +94,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
|
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||||
|
|
||||||
deviceCountText = findViewById(R.id.deviceCountText)
|
deviceCountText = findViewById(R.id.deviceCountText)
|
||||||
statusBadge = findViewById(R.id.statusBadge)
|
statusBadge = findViewById(R.id.statusBadge)
|
||||||
@@ -102,10 +102,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
emptyStateText = findViewById(R.id.emptyStateText)
|
emptyStateText = findViewById(R.id.emptyStateText)
|
||||||
chatListView = findViewById(R.id.chatListView)
|
chatListView = findViewById(R.id.chatListView)
|
||||||
|
|
||||||
chatAdapter = ChatListAdapter(this, chatSummaries)
|
chatAdapter = ChatListAdapter(this, chatItems)
|
||||||
chatListView.adapter = chatAdapter
|
chatListView.adapter = chatAdapter
|
||||||
chatListView.setOnItemClickListener { _, _, position, _ ->
|
chatListView.setOnItemClickListener { _, _, position, _ ->
|
||||||
openChat(chatSummaries[position].peerId)
|
openChat(chatItems[position].peerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusBadge.setOnClickListener { toggleMesh() }
|
statusBadge.setOnClickListener { toggleMesh() }
|
||||||
@@ -164,7 +164,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun showNewChatDialog() {
|
private fun showNewChatDialog() {
|
||||||
val input = EditText(this).apply {
|
val input = EditText(this).apply {
|
||||||
hint = getString(R.string.hint_peer_id)
|
hint = getString(R.string.hint_chat_target)
|
||||||
setSingleLine()
|
setSingleLine()
|
||||||
setPadding(48, 32, 48, 32)
|
setPadding(48, 32, 48, 32)
|
||||||
}
|
}
|
||||||
@@ -172,18 +172,44 @@ class MainActivity : AppCompatActivity() {
|
|||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle(R.string.new_chat_title)
|
.setTitle(R.string.new_chat_title)
|
||||||
.setView(input)
|
.setView(input)
|
||||||
.setPositiveButton(R.string.open_chat) { _, _ ->
|
.setPositiveButton(R.string.open_chat, null)
|
||||||
val peerId = input.text.toString().trim()
|
|
||||||
if (peerId.isNotEmpty()) {
|
|
||||||
openChat(peerId)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.create()
|
||||||
|
.also { dialog ->
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val value = input.text.toString().trim()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val resolvedPeerId = resolvePeerId(value)
|
||||||
|
if (resolvedPeerId != null) {
|
||||||
|
dialog.dismiss()
|
||||||
|
openChat(resolvedPeerId)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
R.string.profile_not_found_locally,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolvePeerId(value: String): String? {
|
||||||
|
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
|
||||||
|
return when {
|
||||||
|
':' in value -> value.trim()
|
||||||
|
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun openChat(peerId: String) {
|
private fun openChat(peerId: String) {
|
||||||
startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId))
|
startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId))
|
||||||
}
|
}
|
||||||
@@ -238,10 +264,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun refreshChats() {
|
private fun refreshChats() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val chats = repository.chatSummaries()
|
val chats = repository.chatSummaries()
|
||||||
chatSummaries.clear()
|
val mappedItems = chats.map { chat ->
|
||||||
chatSummaries.addAll(chats)
|
val profile = repository.profileByPeerId(chat.peerId)
|
||||||
|
val title = profile?.displayName() ?: chat.peerId
|
||||||
|
val subtitlePrefix = profile?.let { "@${it.username} · " }.orEmpty()
|
||||||
|
ChatListItem(
|
||||||
|
peerId = chat.peerId,
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitlePrefix + chat.lastBody,
|
||||||
|
lastStatus = chat.lastStatus,
|
||||||
|
lastTimestamp = chat.lastTimestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chatItems.clear()
|
||||||
|
chatItems.addAll(mappedItems)
|
||||||
chatAdapter.notifyDataSetChanged()
|
chatAdapter.notifyDataSetChanged()
|
||||||
emptyStateText.visibility = if (chats.isEmpty()) View.VISIBLE else View.GONE
|
emptyStateText.visibility = if (mappedItems.isEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,14 +291,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun updateMeshStatus(status: String) {
|
private fun updateMeshStatus(status: String) {
|
||||||
val normalized = status.lowercase(Locale.getDefault())
|
val normalized = status.lowercase(Locale.getDefault())
|
||||||
if (normalized.contains("останов")) {
|
if (normalized.contains("останов") || normalized.contains("оффлайн")) {
|
||||||
meshEnabled = false
|
meshEnabled = false
|
||||||
peers.clear()
|
peers.clear()
|
||||||
} else if (
|
} else if (
|
||||||
normalized.contains("актив") ||
|
normalized.contains("актив") ||
|
||||||
normalized.contains("запуска") ||
|
normalized.contains("запуска") ||
|
||||||
normalized.contains("в сети") ||
|
normalized.contains("в сети") ||
|
||||||
normalized.contains("присутствие") ||
|
normalized.contains("устройство") ||
|
||||||
normalized.contains("сообщение")
|
normalized.contains("сообщение")
|
||||||
) {
|
) {
|
||||||
meshEnabled = true
|
meshEnabled = true
|
||||||
|
|||||||
@@ -1,33 +1,78 @@
|
|||||||
package pro.nnnteam.nnnet
|
package pro.nnnteam.nnnet
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.EditText
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||||
|
import pro.nnnteam.nnnet.data.MeshRepository
|
||||||
|
import pro.nnnteam.nnnet.data.ProfileEntity
|
||||||
|
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
||||||
import pro.nnnteam.nnnet.update.UpdateInfo
|
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||||
import pro.nnnteam.nnnet.update.UpdateManager
|
import pro.nnnteam.nnnet.update.UpdateManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity() {
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
private lateinit var repository: MeshRepository
|
||||||
|
private lateinit var firstNameInput: EditText
|
||||||
|
private lateinit var lastNameInput: EditText
|
||||||
|
private lateinit var usernameInput: EditText
|
||||||
|
private lateinit var descriptionInput: EditText
|
||||||
|
private lateinit var searchInput: EditText
|
||||||
|
private lateinit var profileResultCard: android.view.View
|
||||||
|
private lateinit var resultNameText: TextView
|
||||||
|
private lateinit var resultUsernameText: TextView
|
||||||
|
private lateinit var resultDescriptionText: TextView
|
||||||
|
private lateinit var resultPeerIdText: TextView
|
||||||
|
|
||||||
|
private var receiverRegistered = false
|
||||||
|
|
||||||
private val prefs by lazy {
|
private val prefs by lazy {
|
||||||
getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE)
|
getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val meshEventReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
|
||||||
|
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
|
||||||
|
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
|
||||||
|
val query = searchInput.text.toString().trim()
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
lookupProfile(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_settings)
|
setContentView(R.layout.activity_settings)
|
||||||
|
|
||||||
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
|
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||||
|
|
||||||
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||||
|
|
||||||
|
firstNameInput = findViewById(R.id.firstNameInput)
|
||||||
|
lastNameInput = findViewById(R.id.lastNameInput)
|
||||||
|
usernameInput = findViewById(R.id.usernameInput)
|
||||||
|
descriptionInput = findViewById(R.id.descriptionInput)
|
||||||
|
searchInput = findViewById(R.id.searchInput)
|
||||||
|
profileResultCard = findViewById(R.id.profileResultCard)
|
||||||
|
resultNameText = findViewById(R.id.resultNameText)
|
||||||
|
resultUsernameText = findViewById(R.id.resultUsernameText)
|
||||||
|
resultDescriptionText = findViewById(R.id.resultDescriptionText)
|
||||||
|
resultPeerIdText = findViewById(R.id.resultPeerIdText)
|
||||||
|
|
||||||
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
|
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
|
||||||
val versionText = findViewById<TextView>(R.id.versionText)
|
val versionText = findViewById<TextView>(R.id.versionText)
|
||||||
autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)
|
autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)
|
||||||
@@ -41,14 +86,108 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
currentVersionCode()
|
currentVersionCode()
|
||||||
)
|
)
|
||||||
|
|
||||||
findViewById<android.view.View>(R.id.checkUpdatesButton).setOnClickListener {
|
findViewById<MaterialButton>(R.id.saveProfileButton).setOnClickListener { saveProfile() }
|
||||||
checkForUpdates()
|
findViewById<MaterialButton>(R.id.searchButton).setOnClickListener {
|
||||||
|
val query = searchInput.text.toString().trim()
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
lookupProfile(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() }
|
||||||
|
|
||||||
|
loadLocalProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
registerMeshReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
if (receiverRegistered) {
|
||||||
|
unregisterReceiver(meshEventReceiver)
|
||||||
|
receiverRegistered = false
|
||||||
|
}
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerMeshReceiver() {
|
||||||
|
if (receiverRegistered) return
|
||||||
|
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
registerReceiver(meshEventReceiver, filter)
|
||||||
|
}
|
||||||
|
receiverRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLocalProfile() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val localProfile = repository.localProfile()
|
||||||
|
if (localProfile != null) {
|
||||||
|
firstNameInput.setText(localProfile.firstName)
|
||||||
|
lastNameInput.setText(localProfile.lastName)
|
||||||
|
usernameInput.setText(localProfile.username)
|
||||||
|
descriptionInput.setText(localProfile.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveProfile() {
|
||||||
|
val firstName = firstNameInput.text.toString().trim()
|
||||||
|
val lastName = lastNameInput.text.toString().trim()
|
||||||
|
val username = usernameInput.text.toString().trim().removePrefix("@")
|
||||||
|
val description = descriptionInput.text.toString().trim()
|
||||||
|
|
||||||
|
if (username.isBlank()) {
|
||||||
|
Toast.makeText(this, R.string.username_required, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repository.saveLocalProfile(
|
||||||
|
firstName = firstName,
|
||||||
|
lastName = lastName,
|
||||||
|
username = username,
|
||||||
|
description = description
|
||||||
|
)
|
||||||
|
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupProfile(username: String) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val profile = repository.profileByUsername(username.removePrefix("@"))
|
||||||
|
renderSearchResult(profile)
|
||||||
|
if (profile == null) {
|
||||||
|
Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderSearchResult(profile: ProfileEntity?) {
|
||||||
|
if (profile == null) {
|
||||||
|
profileResultCard.visibility = android.view.View.GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profileResultCard.visibility = android.view.View.VISIBLE
|
||||||
|
resultNameText.text = profile.displayName()
|
||||||
|
resultUsernameText.text = "@${profile.username}"
|
||||||
|
resultDescriptionText.text = profile.description.ifBlank { getString(R.string.no_profile_description) }
|
||||||
|
resultPeerIdText.text = if (profile.peerId.isBlank()) {
|
||||||
|
getString(R.string.peer_id_unknown)
|
||||||
|
} else {
|
||||||
|
getString(R.string.peer_id_value, profile.peerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkForUpdates() {
|
private fun checkForUpdates() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
|
val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
|
||||||
if (updateInfo == null) {
|
if (updateInfo == null) {
|
||||||
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
|
||||||
return@launch
|
return@launch
|
||||||
@@ -63,10 +202,10 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun showUpdateDialog(updateInfo: UpdateInfo) {
|
private fun showUpdateDialog(updateInfo: UpdateInfo) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val releaseNotes = withContext(Dispatchers.IO) {
|
val releaseNotes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath)
|
UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath)
|
||||||
}
|
}
|
||||||
AlertDialog.Builder(this@SettingsActivity)
|
androidx.appcompat.app.AlertDialog.Builder(this@SettingsActivity)
|
||||||
.setTitle(updateInfo.releaseNotesTitle)
|
.setTitle(updateInfo.releaseNotesTitle)
|
||||||
.setMessage(
|
.setMessage(
|
||||||
buildString {
|
buildString {
|
||||||
@@ -79,7 +218,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
.setPositiveButton(R.string.download_update) { _, _ ->
|
.setPositiveButton(R.string.download_update) { _, _ ->
|
||||||
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.later, null)
|
.setNegativeButton(R.string.later, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import androidx.room.Room
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [MessageEntity::class, OutboundQueueEntity::class],
|
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class MeshDatabase : RoomDatabase() {
|
abstract class MeshDatabase : RoomDatabase() {
|
||||||
abstract fun messageDao(): MessageDao
|
abstract fun messageDao(): MessageDao
|
||||||
abstract fun outboundQueueDao(): OutboundQueueDao
|
abstract fun outboundQueueDao(): OutboundQueueDao
|
||||||
|
abstract fun profileDao(): ProfileDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -24,7 +25,10 @@ abstract class MeshDatabase : RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
MeshDatabase::class.java,
|
MeshDatabase::class.java,
|
||||||
"mesh.db"
|
"mesh.db"
|
||||||
).build().also { instance = it }
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
.also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import java.util.UUID
|
|||||||
|
|
||||||
class MeshRepository(
|
class MeshRepository(
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val queueDao: OutboundQueueDao
|
private val queueDao: OutboundQueueDao,
|
||||||
|
private val profileDao: ProfileDao
|
||||||
) {
|
) {
|
||||||
suspend fun enqueueOutgoingMessage(
|
suspend fun enqueueOutgoingMessage(
|
||||||
senderId: String,
|
senderId: String,
|
||||||
@@ -97,8 +98,92 @@ class MeshRepository(
|
|||||||
return messageDao.messagesForPeer(peerId, limit)
|
return messageDao.messagesForPeer(peerId, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun saveLocalProfile(
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
username: String,
|
||||||
|
description: String,
|
||||||
|
peerId: String = "",
|
||||||
|
now: Long = System.currentTimeMillis()
|
||||||
|
): ProfileEntity {
|
||||||
|
val normalizedUsername = normalizeUsername(username)
|
||||||
|
profileDao.deleteLocalProfiles()
|
||||||
|
val entity = ProfileEntity(
|
||||||
|
username = normalizedUsername,
|
||||||
|
firstName = firstName.trim(),
|
||||||
|
lastName = lastName.trim(),
|
||||||
|
description = description.trim(),
|
||||||
|
peerId = peerId,
|
||||||
|
updatedAt = now,
|
||||||
|
lastSeenAt = now,
|
||||||
|
isLocal = true
|
||||||
|
)
|
||||||
|
profileDao.upsert(entity)
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun localProfile(): ProfileEntity? = profileDao.localProfile()
|
||||||
|
|
||||||
|
suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) {
|
||||||
|
if (peerId.isBlank()) return
|
||||||
|
profileDao.updateLocalPeerId(peerId, now, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun upsertRemoteProfile(
|
||||||
|
payload: ProfilePayload,
|
||||||
|
peerId: String,
|
||||||
|
now: Long = System.currentTimeMillis()
|
||||||
|
): ProfileEntity? {
|
||||||
|
val normalizedUsername = normalizeUsername(payload.username)
|
||||||
|
if (normalizedUsername.isBlank()) return null
|
||||||
|
val localProfile = profileDao.localProfile()
|
||||||
|
if (localProfile?.username == normalizedUsername) {
|
||||||
|
return localProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
val existing = profileDao.findByUsername(normalizedUsername)
|
||||||
|
val entity = ProfileEntity(
|
||||||
|
username = normalizedUsername,
|
||||||
|
firstName = payload.firstName.trim(),
|
||||||
|
lastName = payload.lastName.trim(),
|
||||||
|
description = payload.description.trim(),
|
||||||
|
peerId = peerId,
|
||||||
|
updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L),
|
||||||
|
lastSeenAt = now,
|
||||||
|
isLocal = false
|
||||||
|
)
|
||||||
|
profileDao.upsert(entity)
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profileByUsername(username: String): ProfileEntity? {
|
||||||
|
return profileDao.findByUsername(normalizeUsername(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profileByPeerId(peerId: String): ProfileEntity? {
|
||||||
|
if (peerId.isBlank()) return null
|
||||||
|
return profileDao.findByPeerId(peerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchProfiles(query: String, limit: Int = 20): List<ProfileEntity> {
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isBlank()) return emptyList()
|
||||||
|
val exact = profileByUsername(trimmed)
|
||||||
|
val fuzzy = profileDao.search("%$trimmed%", limit)
|
||||||
|
return buildList {
|
||||||
|
if (exact != null) add(exact)
|
||||||
|
fuzzy.forEach { candidate ->
|
||||||
|
if (none { it.username == candidate.username }) {
|
||||||
|
add(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun queuedCount(): Int = queueDao.count()
|
suspend fun queuedCount(): Int = queueDao.count()
|
||||||
|
|
||||||
|
private fun normalizeUsername(value: String): String = value.trim().lowercase()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val STATUS_QUEUED = "queued"
|
const val STATUS_QUEUED = "queued"
|
||||||
const val STATUS_SENT = "sent"
|
const val STATUS_SENT = "sent"
|
||||||
|
|||||||
@@ -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.BluetoothGatt
|
||||||
import android.bluetooth.BluetoothGattCallback
|
import android.bluetooth.BluetoothGattCallback
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
import android.bluetooth.BluetoothProfile
|
|
||||||
import android.bluetooth.BluetoothGattServer
|
import android.bluetooth.BluetoothGattServer
|
||||||
import android.bluetooth.BluetoothGattServerCallback
|
import android.bluetooth.BluetoothGattServerCallback
|
||||||
import android.bluetooth.BluetoothGattService
|
import android.bluetooth.BluetoothGattService
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.bluetooth.BluetoothStatusCodes
|
||||||
import android.bluetooth.le.AdvertiseCallback
|
import android.bluetooth.le.AdvertiseCallback
|
||||||
import android.bluetooth.le.AdvertiseData
|
import android.bluetooth.le.AdvertiseData
|
||||||
import android.bluetooth.le.AdvertiseSettings
|
import android.bluetooth.le.AdvertiseSettings
|
||||||
@@ -21,7 +22,6 @@ import android.bluetooth.le.ScanCallback
|
|||||||
import android.bluetooth.le.ScanFilter
|
import android.bluetooth.le.ScanFilter
|
||||||
import android.bluetooth.le.ScanResult
|
import android.bluetooth.le.ScanResult
|
||||||
import android.bluetooth.le.ScanSettings
|
import android.bluetooth.le.ScanSettings
|
||||||
import android.bluetooth.BluetoothStatusCodes
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -38,6 +38,7 @@ class BleMeshManager(
|
|||||||
private val onStatusChanged: (String) -> Unit = {},
|
private val onStatusChanged: (String) -> Unit = {},
|
||||||
private val onAckReceived: (String) -> Unit = {},
|
private val onAckReceived: (String) -> Unit = {},
|
||||||
private val onMessageReceived: (MeshPacket) -> Unit = {},
|
private val onMessageReceived: (MeshPacket) -> Unit = {},
|
||||||
|
private val onProfileReceived: (MeshPacket) -> Unit = {},
|
||||||
private val onError: (String) -> Unit = {},
|
private val onError: (String) -> Unit = {},
|
||||||
private val onLog: (String) -> Unit = {},
|
private val onLog: (String) -> Unit = {},
|
||||||
private val seenPacketCache: SeenPacketCache = SeenPacketCache()
|
private val seenPacketCache: SeenPacketCache = SeenPacketCache()
|
||||||
@@ -110,7 +111,7 @@ class BleMeshManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val rawPacket = value.toString(StandardCharsets.UTF_8)
|
val rawPacket = value.toString(StandardCharsets.UTF_8)
|
||||||
log("Packet received from ${device.address}: $rawPacket")
|
log("Пакет получен от ${device.address}: $rawPacket")
|
||||||
handleIncomingPacket(rawPacket)
|
handleIncomingPacket(rawPacket)
|
||||||
|
|
||||||
if (responseNeeded) {
|
if (responseNeeded) {
|
||||||
@@ -125,20 +126,20 @@ class BleMeshManager(
|
|||||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||||
val address = device.address ?: return
|
val address = device.address ?: return
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
log("GATT client error for $address: status=$status")
|
log("Ошибка GATT-клиента для $address: status=$status")
|
||||||
closeConnection(address)
|
closeConnection(address)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (newState) {
|
when (newState) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
log("Connected to peer $address")
|
log("Подключено к узлу $address")
|
||||||
activeConnections[address] = gatt
|
activeConnections[address] = gatt
|
||||||
gatt.discoverServices()
|
gatt.discoverServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
log("Disconnected from peer $address")
|
log("Узел отключился: $address")
|
||||||
closeConnection(address)
|
closeConnection(address)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,11 +147,12 @@ class BleMeshManager(
|
|||||||
|
|
||||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
log("Service discovery failed for ${device.address}: $status")
|
log("Не удалось обнаружить сервисы у ${device.address}: $status")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("Services discovered for ${device.address}")
|
log("Сервисы обнаружены у ${device.address}")
|
||||||
sendPresence(gatt)
|
sendPresence(gatt)
|
||||||
|
device.address?.let(onPeerDiscovered)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCharacteristicWrite(
|
override fun onCharacteristicWrite(
|
||||||
@@ -160,9 +162,9 @@ class BleMeshManager(
|
|||||||
) {
|
) {
|
||||||
val address = device.address ?: return
|
val address = device.address ?: return
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
log("Packet sent to $address")
|
log("Пакет отправлен на $address")
|
||||||
} else {
|
} else {
|
||||||
log("Packet send failed to $address: status=$status")
|
log("Ошибка отправки пакета на $address: status=$status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,9 +180,24 @@ class BleMeshManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return when (packet.type) {
|
return when (packet.type) {
|
||||||
PacketType.ACK -> MeshAction.ConsumeAck(packet.payload)
|
PacketType.ACK -> {
|
||||||
|
if (packet.targetId == localNodeId) {
|
||||||
|
MeshAction.ConsumeAck(packet.payload)
|
||||||
|
} else {
|
||||||
|
MeshAction.Relay(packet.decrementedTtl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId)
|
PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId)
|
||||||
PacketType.MESSAGE -> MeshAction.ProcessAndRelay(packet.decrementedTtl())
|
PacketType.MESSAGE -> {
|
||||||
|
if (packet.targetId == localNodeId) {
|
||||||
|
MeshAction.DeliverMessage(packet)
|
||||||
|
} else {
|
||||||
|
MeshAction.Relay(packet.decrementedTtl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketType.PROFILE -> MeshAction.CacheProfile(packet, packet.decrementedTtl())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +259,25 @@ class BleMeshManager(
|
|||||||
onStatusChanged("Устройство ${action.senderId} рядом")
|
onStatusChanged("Устройство ${action.senderId} рядом")
|
||||||
log("Сигнал присутствия обработан от ${action.senderId}")
|
log("Сигнал присутствия обработан от ${action.senderId}")
|
||||||
}
|
}
|
||||||
|
is MeshAction.DeliverMessage -> {
|
||||||
is MeshAction.ProcessAndRelay -> {
|
onMessageReceived(action.packet)
|
||||||
onMessageReceived(packet)
|
onStatusChanged("Новое сообщение от ${action.packet.senderId}")
|
||||||
onStatusChanged("Новое сообщение от ${packet.senderId}")
|
sendAck(action.packet)
|
||||||
log("Ретрансляция пакета ${packet.messageId}")
|
|
||||||
broadcastPacket(action.packetToRelay)
|
|
||||||
sendAck(packet)
|
|
||||||
}
|
}
|
||||||
|
is MeshAction.CacheProfile -> {
|
||||||
|
onProfileReceived(action.packet)
|
||||||
|
broadcastIfAlive(action.packetToRelay)
|
||||||
|
}
|
||||||
|
is MeshAction.Relay -> {
|
||||||
|
log("Ретрансляция пакета ${action.packetToRelay.messageId}")
|
||||||
|
broadcastIfAlive(action.packetToRelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastIfAlive(packet: MeshPacket) {
|
||||||
|
if (!packet.isExpired()) {
|
||||||
|
broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +290,7 @@ class BleMeshManager(
|
|||||||
|
|
||||||
val server = manager.openGattServer(context, gattServerCallback)
|
val server = manager.openGattServer(context, gattServerCallback)
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
fail("Failed to open GATT server")
|
fail("Не удалось открыть GATT server")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +346,7 @@ class BleMeshManager(
|
|||||||
private fun connectToPeer(device: BluetoothDevice) {
|
private fun connectToPeer(device: BluetoothDevice) {
|
||||||
val address = device.address ?: return
|
val address = device.address ?: return
|
||||||
if (activeConnections.containsKey(address)) return
|
if (activeConnections.containsKey(address)) return
|
||||||
log("Connecting to peer $address")
|
log("Подключение к узлу $address")
|
||||||
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE)
|
device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,7 +361,7 @@ class BleMeshManager(
|
|||||||
private fun sendPresence(gatt: BluetoothGatt) {
|
private fun sendPresence(gatt: BluetoothGatt) {
|
||||||
val packet = MeshPacket(
|
val packet = MeshPacket(
|
||||||
senderId = localNodeId,
|
senderId = localNodeId,
|
||||||
targetId = gatt.device.address ?: "broadcast",
|
targetId = gatt.device.address ?: BROADCAST_TARGET,
|
||||||
type = PacketType.PRESENCE,
|
type = PacketType.PRESENCE,
|
||||||
payload = "presence:$localNodeId"
|
payload = "presence:$localNodeId"
|
||||||
)
|
)
|
||||||
@@ -374,7 +402,7 @@ class BleMeshManager(
|
|||||||
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
|
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
|
||||||
|
|
||||||
if (characteristic == null) {
|
if (characteristic == null) {
|
||||||
log("Remote characteristic missing on ${gatt.device.address}")
|
log("У удалённого узла нет mesh-характеристики: ${gatt.device.address}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +463,7 @@ class BleMeshManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val BROADCAST_TARGET = "*"
|
||||||
private const val TAG = "BleMeshManager"
|
private const val TAG = "BleMeshManager"
|
||||||
private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b")
|
private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b")
|
||||||
private val CHARACTERISTIC_PACKET_UUID: UUID =
|
private val CHARACTERISTIC_PACKET_UUID: UUID =
|
||||||
@@ -447,5 +476,7 @@ sealed interface MeshAction {
|
|||||||
data object DropExpired : MeshAction
|
data object DropExpired : MeshAction
|
||||||
data class ConsumeAck(val messageId: String) : MeshAction
|
data class ConsumeAck(val messageId: String) : MeshAction
|
||||||
data class ConsumePresence(val senderId: String) : MeshAction
|
data class ConsumePresence(val senderId: String) : MeshAction
|
||||||
data class ProcessAndRelay(val packetToRelay: MeshPacket) : MeshAction
|
data class DeliverMessage(val packet: MeshPacket) : MeshAction
|
||||||
|
data class CacheProfile(val packet: MeshPacket, val packetToRelay: MeshPacket) : MeshAction
|
||||||
|
data class Relay(val packetToRelay: MeshPacket) : MeshAction
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,32 +9,38 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import pro.nnnteam.nnnet.R
|
|
||||||
import pro.nnnteam.nnnet.data.MeshDatabase
|
|
||||||
import pro.nnnteam.nnnet.data.MeshRepository
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import pro.nnnteam.nnnet.R
|
||||||
|
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||||
|
import pro.nnnteam.nnnet.data.MeshRepository
|
||||||
|
import pro.nnnteam.nnnet.data.ProfilePayload
|
||||||
|
import pro.nnnteam.nnnet.data.ProfilePayloadCodec
|
||||||
|
|
||||||
class MeshForegroundService : Service() {
|
class MeshForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private lateinit var bleMeshManager: BleMeshManager
|
private lateinit var bleMeshManager: BleMeshManager
|
||||||
private lateinit var repository: MeshRepository
|
private lateinit var repository: MeshRepository
|
||||||
private lateinit var queueProcessor: MeshQueueProcessor
|
private lateinit var queueProcessor: MeshQueueProcessor
|
||||||
|
private var lastProfileBroadcastAt = 0L
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
|
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||||
bleMeshManager = BleMeshManager(
|
bleMeshManager = BleMeshManager(
|
||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
onPeerDiscovered = { address ->
|
onPeerDiscovered = { address ->
|
||||||
sendEvent(MeshServiceContract.EVENT_PEER, address)
|
sendEvent(MeshServiceContract.EVENT_PEER, address)
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
|
serviceScope.launch {
|
||||||
|
publishLocalProfile(force = false)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onStatusChanged = { status ->
|
onStatusChanged = { status ->
|
||||||
sendEvent(MeshServiceContract.EVENT_STATUS, status)
|
sendEvent(MeshServiceContract.EVENT_STATUS, status)
|
||||||
@@ -54,6 +60,16 @@ class MeshForegroundService : Service() {
|
|||||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
|
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onProfileReceived = { packet ->
|
||||||
|
serviceScope.launch {
|
||||||
|
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
|
||||||
|
val stored = repository.upsertRemoteProfile(payload, packet.senderId)
|
||||||
|
if (stored != null) {
|
||||||
|
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}")
|
||||||
|
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onError = { message ->
|
onError = { message ->
|
||||||
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
|
||||||
@@ -94,6 +110,41 @@ class MeshForegroundService : Service() {
|
|||||||
bleMeshManager.start()
|
bleMeshManager.start()
|
||||||
queueProcessor.start()
|
queueProcessor.start()
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
|
serviceScope.launch {
|
||||||
|
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
||||||
|
publishLocalProfile(force = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun publishLocalProfile(force: Boolean) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - lastProfileBroadcastAt < PROFILE_BROADCAST_THROTTLE_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
||||||
|
val localProfile = repository.localProfile() ?: return
|
||||||
|
val payload = ProfilePayload(
|
||||||
|
firstName = localProfile.firstName,
|
||||||
|
lastName = localProfile.lastName,
|
||||||
|
username = localProfile.username,
|
||||||
|
description = localProfile.description,
|
||||||
|
updatedAt = localProfile.updatedAt
|
||||||
|
)
|
||||||
|
val sent = bleMeshManager.sendPacket(
|
||||||
|
MeshPacket(
|
||||||
|
senderId = bleMeshManager.nodeId,
|
||||||
|
targetId = BleMeshManager.BROADCAST_TARGET,
|
||||||
|
type = PacketType.PROFILE,
|
||||||
|
payload = ProfilePayloadCodec.encode(payload)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (sent) {
|
||||||
|
lastProfileBroadcastAt = now
|
||||||
|
}
|
||||||
|
if (sent || force) {
|
||||||
|
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, localProfile.username)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopMesh() {
|
private fun stopMesh() {
|
||||||
@@ -160,6 +211,7 @@ class MeshForegroundService : Service() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val CHANNEL_ID = "mesh_status"
|
private const val CHANNEL_ID = "mesh_status"
|
||||||
private const val NOTIFICATION_ID = 1001
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val PROFILE_BROADCAST_THROTTLE_MS = 5_000L
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, MeshForegroundService::class.java).apply {
|
val intent = Intent(context, MeshForegroundService::class.java).apply {
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ object MeshServiceContract {
|
|||||||
const val EVENT_PEER = "peer"
|
const val EVENT_PEER = "peer"
|
||||||
const val EVENT_LOG = "log"
|
const val EVENT_LOG = "log"
|
||||||
const val EVENT_MESSAGES_CHANGED = "messages_changed"
|
const val EVENT_MESSAGES_CHANGED = "messages_changed"
|
||||||
|
const val EVENT_PROFILES_CHANGED = "profiles_changed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package pro.nnnteam.nnnet.mesh
|
|||||||
enum class PacketType {
|
enum class PacketType {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
ACK,
|
ACK,
|
||||||
PRESENCE
|
PRESENCE,
|
||||||
|
PROFILE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,20 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import pro.nnnteam.nnnet.R
|
import pro.nnnteam.nnnet.R
|
||||||
import pro.nnnteam.nnnet.data.ChatSummary
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class ChatListAdapter(
|
class ChatListAdapter(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val items: MutableList<ChatSummary>
|
private val items: MutableList<ChatListItem>
|
||||||
) : BaseAdapter() {
|
) : BaseAdapter() {
|
||||||
private val inflater = LayoutInflater.from(context)
|
private val inflater = LayoutInflater.from(context)
|
||||||
private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru"))
|
private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru"))
|
||||||
|
|
||||||
override fun getCount(): Int = items.size
|
override fun getCount(): Int = items.size
|
||||||
|
|
||||||
override fun getItem(position: Int): ChatSummary = items[position]
|
override fun getItem(position: Int): ChatListItem = items[position]
|
||||||
|
|
||||||
override fun getItemId(position: Int): Long = position.toLong()
|
override fun getItemId(position: Int): Long = position.toLong()
|
||||||
|
|
||||||
@@ -29,16 +28,16 @@ class ChatListAdapter(
|
|||||||
val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false)
|
val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false)
|
||||||
val item = getItem(position)
|
val item = getItem(position)
|
||||||
|
|
||||||
view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.peerId)
|
view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.title)
|
||||||
view.findViewById<TextView>(R.id.chatNameText).text = item.peerId
|
view.findViewById<TextView>(R.id.chatNameText).text = item.title
|
||||||
view.findViewById<TextView>(R.id.chatPreviewText).text = item.lastBody
|
view.findViewById<TextView>(R.id.chatPreviewText).text = item.subtitle
|
||||||
view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp))
|
view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp))
|
||||||
view.findViewById<TextView>(R.id.chatStatusText).text = statusLabel(item.lastStatus)
|
view.findViewById<TextView>(R.id.chatStatusText).text = statusLabel(item.lastStatus)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun avatarLetter(peerId: String): String = peerId.firstOrNull()?.uppercase() ?: "N"
|
private fun avatarLetter(title: String): String = title.firstOrNull()?.uppercase() ?: "N"
|
||||||
|
|
||||||
private fun statusLabel(status: String): String = when (status) {
|
private fun statusLabel(status: String): String = when (status) {
|
||||||
"queued" -> "В очереди"
|
"queued" -> "В очереди"
|
||||||
|
|||||||
@@ -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,16 +35,156 @@
|
|||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/profile_section_title"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/firstNameInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:hint="@string/first_name"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textColorHint="@color/secondary_text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/lastNameInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:hint="@string/last_name"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textColorHint="@color/secondary_text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/usernameInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:hint="@string/username"
|
||||||
|
android:inputType="textNoSuggestions"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textColorHint="@color/secondary_text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/descriptionInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:gravity="top"
|
||||||
|
android:hint="@string/profile_description"
|
||||||
|
android:minLines="3"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textColorHint="@color/secondary_text" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/saveProfileButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/save_profile"
|
||||||
|
app:cornerRadius="18dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="@string/search_profile_title"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/searchInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:hint="@string/search_username"
|
||||||
|
android:inputType="textNoSuggestions"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textColorHint="@color/secondary_text" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/searchButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/find_profile"
|
||||||
|
app:cornerRadius="18dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/profileResultCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:background="@drawable/bg_settings_card"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/resultNameText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/resultUsernameText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/accent_blue"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/resultDescriptionText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/secondary_text"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/resultPeerIdText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/primary_text"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/versionText"
|
android:id="@+id/versionText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
android:background="@drawable/bg_settings_card"
|
android:background="@drawable/bg_settings_card"
|
||||||
android:padding="16dp"
|
android:padding="16dp"
|
||||||
android:textColor="@color/primary_text"
|
android:textColor="@color/primary_text"
|
||||||
@@ -69,4 +209,5 @@
|
|||||||
android:text="@string/check_updates"
|
android:text="@string/check_updates"
|
||||||
app:cornerRadius="18dp" />
|
app:cornerRadius="18dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
<string name="new_chat">Новый чат</string>
|
<string name="new_chat">Новый чат</string>
|
||||||
<string name="new_chat_title">Новый диалог</string>
|
<string name="new_chat_title">Новый диалог</string>
|
||||||
<string name="hint_peer_id">Идентификатор устройства</string>
|
<string name="hint_peer_id">Идентификатор устройства</string>
|
||||||
|
<string name="hint_chat_target">Username или peerId</string>
|
||||||
<string name="open_chat">Открыть чат</string>
|
<string name="open_chat">Открыть чат</string>
|
||||||
<string name="cancel">Отмена</string>
|
<string name="cancel">Отмена</string>
|
||||||
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
|
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
|
||||||
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
|
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
|
||||||
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
|
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
|
||||||
<string name="peer_id_required">Введите ID устройства</string>
|
<string name="peer_id_required">Введите ID устройства</string>
|
||||||
|
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
|
||||||
<string name="update_check_failed">Не удалось проверить обновления</string>
|
<string name="update_check_failed">Не удалось проверить обновления</string>
|
||||||
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
|
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
|
||||||
<string name="update_available_message">Доступна версия %1$s.</string>
|
<string name="update_available_message">Доступна версия %1$s.</string>
|
||||||
@@ -34,4 +36,19 @@
|
|||||||
<string name="auto_update">Автоматически проверять обновления</string>
|
<string name="auto_update">Автоматически проверять обновления</string>
|
||||||
<string name="check_updates">Проверить обновления</string>
|
<string name="check_updates">Проверить обновления</string>
|
||||||
<string name="current_version">Текущая версия: %1$s (%2$d)</string>
|
<string name="current_version">Текущая версия: %1$s (%2$d)</string>
|
||||||
|
<string name="profile_section_title">Мой профиль</string>
|
||||||
|
<string name="first_name">Имя</string>
|
||||||
|
<string name="last_name">Фамилия</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="profile_description">Описание</string>
|
||||||
|
<string name="save_profile">Сохранить профиль</string>
|
||||||
|
<string name="search_profile_title">Найти профиль</string>
|
||||||
|
<string name="search_username">Введите username</string>
|
||||||
|
<string name="find_profile">Найти</string>
|
||||||
|
<string name="enter_username_to_search">Введите username для поиска</string>
|
||||||
|
<string name="username_required">Username обязателен</string>
|
||||||
|
<string name="profile_saved">Профиль сохранён</string>
|
||||||
|
<string name="no_profile_description">Описание не указано</string>
|
||||||
|
<string name="peer_id_unknown">peerId пока неизвестен</string>
|
||||||
|
<string name="peer_id_value">peerId: %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
## Слои
|
## Слои
|
||||||
- BLE Transport: сканирование, реклама, соединения, обмен пакетами.
|
- BLE Transport: сканирование, реклама, соединения, обмен пакетами.
|
||||||
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK.
|
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
|
||||||
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
|
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
|
||||||
- Storage Layer: Room для локального хранения.
|
- Storage Layer: Room для локального хранения сообщений, очереди и профилей.
|
||||||
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
||||||
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
||||||
|
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`.
|
||||||
|
|
||||||
## Пользовательский сценарий
|
## Пользовательский сценарий
|
||||||
- Главный экран показывает список чатов в стиле Telegram.
|
- Главный экран показывает список чатов в стиле Telegram.
|
||||||
@@ -14,11 +15,13 @@
|
|||||||
- Слева в шапке показывается общее количество известных устройств в mesh.
|
- Слева в шапке показывается общее количество известных устройств в mesh.
|
||||||
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
|
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||||
- Отправка сообщений доступна только из экрана конкретного диалога.
|
- Отправка сообщений доступна только из экрана конкретного диалога.
|
||||||
|
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
|
||||||
|
|
||||||
## Топология сети
|
## Топология сети
|
||||||
- Выделенный сервер или хост для работы mesh не нужен.
|
- Выделенный сервер или хост для работы mesh не нужен.
|
||||||
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
||||||
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
||||||
|
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
|
||||||
|
|
||||||
## Сетевой пакет (черновик)
|
## Сетевой пакет (черновик)
|
||||||
```json
|
```json
|
||||||
@@ -28,12 +31,12 @@
|
|||||||
"targetId": "user-or-group-id",
|
"targetId": "user-or-group-id",
|
||||||
"ttl": 6,
|
"ttl": 6,
|
||||||
"timestamp": 0,
|
"timestamp": 0,
|
||||||
"type": "message|ack|presence",
|
"type": "message|ack|presence|profile",
|
||||||
"payload": "base64-or-json"
|
"payload": "base64-or-json"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ближайшие шаги
|
## Ближайшие шаги
|
||||||
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
||||||
2. Ввести шифрование payload и управление профилями пользователей.
|
2. Ввести шифрование payload и подпись пакетов.
|
||||||
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.
|
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div>
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div>
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений и очередь исходящей доставки.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений, очередь доставки и кэш профилей пользователей.</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user