Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca8a5cb86 | ||
|
|
6c715477b4 | ||
|
|
0d40a0ae2a | ||
|
|
da681cbd23 | ||
|
|
c158fd63b6 | ||
|
|
909d1462f7 |
19
README.md
19
README.md
@@ -13,8 +13,14 @@
|
||||
- Реализован минимальный GATT transport для обмена mesh-пакетами.
|
||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
||||
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
|
||||
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
|
||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
|
||||
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`, имени, фамилии, полному имени и `peerId`.
|
||||
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`, и наоборот по `peerId` в UI показывается связанный `@username`, если claim уже известен.
|
||||
- Username оформлен как подписанный lease-claim на 14 дней: если владелец не появляется в сети 2 недели, username снова становится свободным.
|
||||
- Добавлен recovery bundle для переноса профиля и права на username на новый телефон даже без доступа к старому устройству.
|
||||
- Добавлена защита от захвата чужого `username`: активный claim другого владельца не принимается, а смена `peerId` без подписи владельца не даёт забрать профиль.
|
||||
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
|
||||
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
|
||||
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
||||
- Публикация APK и сайта автоматизирована через `Makefile`.
|
||||
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
|
||||
@@ -50,7 +56,7 @@
|
||||
|
||||
4. **Data Layer**
|
||||
- локальное хранилище (Room);
|
||||
- история сообщений, очередь исходящей доставки и каталог профилей.
|
||||
- история сообщений, очередь исходящей доставки, каталог профилей и журнал пакетов.
|
||||
|
||||
5. **Security Layer**
|
||||
- идентификация пользователя;
|
||||
@@ -85,6 +91,12 @@
|
||||
- [x] Подключить Room и базовую схему хранения.
|
||||
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
|
||||
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
|
||||
- [x] Добавить поиск профилей по имени, фамилии, полному имени и `peerId`.
|
||||
- [x] Добавить lease-механику для `username` с автоматическим освобождением через 14 дней неактивности.
|
||||
- [x] Добавить перенос профиля на новый телефон через recovery bundle.
|
||||
- [x] Добавить защиту от захвата чужого `username` и подмены `peerId` в профиле.
|
||||
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
|
||||
- [x] Добавить режим карты сети в настройках.
|
||||
- [x] Добавить логирование сети и debug-экран маршрутов.
|
||||
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
||||
- [ ] Добавить шифрование полезной нагрузки сообщений.
|
||||
@@ -118,4 +130,5 @@
|
||||
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
||||
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
||||
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
|
||||
- Право на `username` определяется не текущим `peerId`, а подписанным claim владельца. Это защищает имя от простого копирования `peerId` другим устройством.
|
||||
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "pro.nnnteam.nnnet"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 4
|
||||
versionName = "0.1.3"
|
||||
versionCode = 6
|
||||
versionName = "0.1.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -17,14 +17,21 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:icon="@drawable/ic_nnnet_app"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:roundIcon="@drawable/ic_nnnet_app"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.NNNet">
|
||||
<activity
|
||||
android:name=".PacketLogActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".PacketMapActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="false" />
|
||||
@@ -47,6 +54,16 @@
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -85,7 +85,13 @@ class ChatActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
|
||||
titleText = findViewById(R.id.chatTitleText)
|
||||
subtitleText = findViewById(R.id.chatSubtitleText)
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.UpdateInstaller
|
||||
import pro.nnnteam.nnnet.update.UpdateManager
|
||||
import java.util.Locale
|
||||
|
||||
@@ -94,7 +95,13 @@ class MainActivity : AppCompatActivity() {
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
|
||||
deviceCountText = findViewById(R.id.deviceCountText)
|
||||
statusBadge = findViewById(R.id.statusBadge)
|
||||
@@ -151,6 +158,14 @@ class MainActivity : AppCompatActivity() {
|
||||
menuInflater.inflate(R.menu.main_menu, menu)
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.menu_map -> {
|
||||
startActivity(Intent(this@MainActivity, PacketMapActivity::class.java))
|
||||
true
|
||||
}
|
||||
R.id.menu_packets -> {
|
||||
startActivity(Intent(this@MainActivity, PacketLogActivity::class.java))
|
||||
true
|
||||
}
|
||||
R.id.menu_settings -> {
|
||||
startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
|
||||
true
|
||||
@@ -203,11 +218,12 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
val query = value.trim()
|
||||
val foundPeerId = repository.searchProfiles(query, limit = 1)
|
||||
.firstOrNull()
|
||||
?.peerId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return foundPeerId ?: query.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun openChat(peerId: String) {
|
||||
@@ -354,8 +370,16 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
)
|
||||
.setPositiveButton(R.string.download_update) { _, _ ->
|
||||
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
|
||||
lifecycleScope.launch {
|
||||
val apkFile = withContext(Dispatchers.IO) {
|
||||
UpdateInstaller.downloadToTempFile(this@MainActivity, updateInfo)
|
||||
}
|
||||
if (apkFile == null) {
|
||||
Toast.makeText(this@MainActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
UpdateInstaller.installDownloadedApk(this@MainActivity, apkFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.later, null)
|
||||
.show()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package pro.nnnteam.nnnet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ListView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlinx.coroutines.launch
|
||||
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||
import pro.nnnteam.nnnet.data.MeshRepository
|
||||
import pro.nnnteam.nnnet.ui.PacketTraceAdapter
|
||||
|
||||
class PacketLogActivity : AppCompatActivity() {
|
||||
private lateinit var repository: MeshRepository
|
||||
private lateinit var adapter: PacketTraceAdapter
|
||||
private val items = mutableListOf<pro.nnnteam.nnnet.data.PacketTraceSummary>()
|
||||
private var currentDirection = MeshRepository.TRACE_OUTGOING
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_packet_log)
|
||||
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
|
||||
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||
adapter = PacketTraceAdapter(this, items)
|
||||
findViewById<ListView>(R.id.packetListView).adapter = adapter
|
||||
|
||||
findViewById<MaterialButton>(R.id.outgoingButton).setOnClickListener {
|
||||
currentDirection = MeshRepository.TRACE_OUTGOING
|
||||
refresh()
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.incomingButton).setOnClickListener {
|
||||
currentDirection = MeshRepository.TRACE_INCOMING
|
||||
refresh()
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.relayButton).setOnClickListener {
|
||||
currentDirection = MeshRepository.TRACE_RELAY
|
||||
refresh()
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
lifecycleScope.launch {
|
||||
val loaded = repository.recentPacketSummaries(currentDirection)
|
||||
items.clear()
|
||||
items.addAll(loaded)
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package pro.nnnteam.nnnet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||
import pro.nnnteam.nnnet.data.MeshRepository
|
||||
import pro.nnnteam.nnnet.ui.MapNodeUi
|
||||
import pro.nnnteam.nnnet.ui.NetworkMapView
|
||||
|
||||
class PacketMapActivity : AppCompatActivity() {
|
||||
private lateinit var repository: MeshRepository
|
||||
private lateinit var mapView: NetworkMapView
|
||||
private lateinit var mapMetaText: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_packet_map)
|
||||
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
|
||||
mapView = findViewById(R.id.mapView)
|
||||
mapMetaText = findViewById(R.id.mapMetaText)
|
||||
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||
|
||||
renderMap()
|
||||
}
|
||||
|
||||
private fun renderMap() {
|
||||
lifecycleScope.launch {
|
||||
val local = repository.localProfile()
|
||||
val remoteNodes = repository.mapNodes()
|
||||
val nodes = buildList {
|
||||
add(
|
||||
MapNodeUi(
|
||||
label = local?.displayName() ?: getString(R.string.you_label),
|
||||
peerId = local?.peerId.orEmpty(),
|
||||
isSelf = true
|
||||
)
|
||||
)
|
||||
remoteNodes
|
||||
.filter { it.peerId != local?.peerId }
|
||||
.take(16)
|
||||
.forEach { profile ->
|
||||
add(
|
||||
MapNodeUi(
|
||||
label = profile.displayName(),
|
||||
peerId = profile.peerId,
|
||||
isSelf = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
mapView.submitNodes(nodes)
|
||||
mapMetaText.text = getString(R.string.map_mode_hint, nodes.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
package pro.nnnteam.nnnet
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ListView
|
||||
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
|
||||
@@ -13,10 +18,14 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import kotlinx.coroutines.launch
|
||||
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||
import pro.nnnteam.nnnet.data.MeshRepository
|
||||
import pro.nnnteam.nnnet.data.ProfileSaveResult
|
||||
import pro.nnnteam.nnnet.data.ProfileEntity
|
||||
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
||||
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||
import pro.nnnteam.nnnet.update.UpdateInstaller
|
||||
import pro.nnnteam.nnnet.update.UpdateManager
|
||||
import pro.nnnteam.nnnet.ui.ChatListItem
|
||||
import pro.nnnteam.nnnet.ui.ChatListAdapter
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@@ -33,6 +42,12 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var resultUsernameText: TextView
|
||||
private lateinit var resultDescriptionText: TextView
|
||||
private lateinit var resultPeerIdText: TextView
|
||||
private lateinit var updateProgressText: TextView
|
||||
private lateinit var recoveryInput: EditText
|
||||
private lateinit var searchResultsView: ListView
|
||||
private val searchProfileResults = mutableListOf<ProfileEntity>()
|
||||
private val searchItems = mutableListOf<ChatListItem>()
|
||||
private lateinit var searchAdapter: ChatListAdapter
|
||||
|
||||
private var receiverRegistered = false
|
||||
|
||||
@@ -47,7 +62,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
|
||||
val query = searchInput.text.toString().trim()
|
||||
if (query.isNotEmpty()) {
|
||||
lookupProfile(query)
|
||||
searchProfiles(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +73,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
|
||||
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||
|
||||
@@ -72,6 +93,14 @@ class SettingsActivity : AppCompatActivity() {
|
||||
resultUsernameText = findViewById(R.id.resultUsernameText)
|
||||
resultDescriptionText = findViewById(R.id.resultDescriptionText)
|
||||
resultPeerIdText = findViewById(R.id.resultPeerIdText)
|
||||
updateProgressText = findViewById(R.id.updateProgressText)
|
||||
recoveryInput = findViewById(R.id.recoveryInput)
|
||||
searchResultsView = findViewById(R.id.searchResultsView)
|
||||
searchAdapter = ChatListAdapter(this, searchItems)
|
||||
searchResultsView.adapter = searchAdapter
|
||||
searchResultsView.setOnItemClickListener { _, _, position, _ ->
|
||||
renderSearchResult(searchProfileResults.getOrNull(position))
|
||||
}
|
||||
|
||||
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
|
||||
val versionText = findViewById<TextView>(R.id.versionText)
|
||||
@@ -92,9 +121,21 @@ class SettingsActivity : AppCompatActivity() {
|
||||
if (query.isEmpty()) {
|
||||
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
lookupProfile(query)
|
||||
searchProfiles(query)
|
||||
}
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.showRecoveryButton).setOnClickListener {
|
||||
showRecoveryBundle()
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.importRecoveryButton).setOnClickListener {
|
||||
importRecoveryBundle()
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.openMapButton).setOnClickListener {
|
||||
startActivity(Intent(this, PacketMapActivity::class.java))
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.openPacketLogButton).setOnClickListener {
|
||||
startActivity(Intent(this, PacketLogActivity::class.java))
|
||||
}
|
||||
findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() }
|
||||
|
||||
loadLocalProfile()
|
||||
@@ -149,26 +190,98 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repository.saveLocalProfile(
|
||||
when (val result = repository.saveLocalProfile(
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
username = username,
|
||||
description = description
|
||||
)
|
||||
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
|
||||
)) {
|
||||
is ProfileSaveResult.Saved -> {
|
||||
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
|
||||
usernameInput.setText(result.profile.username)
|
||||
}
|
||||
is ProfileSaveResult.UsernameOccupied -> {
|
||||
Toast.makeText(this@SettingsActivity, R.string.username_taken, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
ProfileSaveResult.InvalidUsername -> {
|
||||
Toast.makeText(this@SettingsActivity, R.string.username_invalid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lookupProfile(username: String) {
|
||||
private fun searchProfiles(query: String) {
|
||||
lifecycleScope.launch {
|
||||
val profile = repository.profileByUsername(username.removePrefix("@"))
|
||||
renderSearchResult(profile)
|
||||
if (profile == null) {
|
||||
val profiles = repository.searchProfiles(query)
|
||||
searchProfileResults.clear()
|
||||
searchProfileResults.addAll(profiles)
|
||||
searchItems.clear()
|
||||
searchItems.addAll(
|
||||
profiles.map { profile ->
|
||||
ChatListItem(
|
||||
peerId = profile.peerId,
|
||||
title = profile.displayName(),
|
||||
subtitle = buildString {
|
||||
append("@${profile.username}")
|
||||
if (profile.description.isNotBlank()) {
|
||||
append(" · ${profile.description}")
|
||||
} else if (profile.peerId.isNotBlank()) {
|
||||
append(" · ${profile.peerId}")
|
||||
}
|
||||
},
|
||||
lastStatus = "",
|
||||
lastTimestamp = maxOf(profile.updatedAt, profile.lastSeenAt)
|
||||
)
|
||||
}
|
||||
)
|
||||
searchAdapter.notifyDataSetChanged()
|
||||
val first = profiles.firstOrNull()
|
||||
renderSearchResult(first)
|
||||
if (first == null) {
|
||||
Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showRecoveryBundle() {
|
||||
val bundle = repository.exportRecoveryBundle()
|
||||
recoveryInput.setText(bundle)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.recovery_dialog_title)
|
||||
.setMessage(R.string.recovery_dialog_message)
|
||||
.setView(EditText(this).apply {
|
||||
setText(bundle)
|
||||
setTextIsSelectable(true)
|
||||
isSingleLine = false
|
||||
minLines = 6
|
||||
setPadding(48, 32, 48, 32)
|
||||
})
|
||||
.setPositiveButton(R.string.copy_code) { _, _ ->
|
||||
val clipboard = getSystemService(ClipboardManager::class.java)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("NNNet Recovery", bundle))
|
||||
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton(R.string.ok, null)
|
||||
.show()
|
||||
Toast.makeText(this, R.string.recovery_shown, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun importRecoveryBundle() {
|
||||
val bundle = recoveryInput.text.toString().trim()
|
||||
if (bundle.isBlank()) {
|
||||
Toast.makeText(this, R.string.recovery_required, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
repository.importRecoveryBundle(bundle)
|
||||
}.onSuccess {
|
||||
Toast.makeText(this, R.string.recovery_imported, Toast.LENGTH_SHORT).show()
|
||||
loadLocalProfile()
|
||||
}.onFailure {
|
||||
Toast.makeText(this, R.string.recovery_invalid, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderSearchResult(profile: ProfileEntity?) {
|
||||
if (profile == null) {
|
||||
profileResultCard.visibility = android.view.View.GONE
|
||||
@@ -187,14 +300,17 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private fun checkForUpdates() {
|
||||
lifecycleScope.launch {
|
||||
showUpdateProgress(getString(R.string.update_checking))
|
||||
val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
|
||||
if (updateInfo == null) {
|
||||
hideUpdateProgress()
|
||||
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
if (updateInfo.versionCode > currentVersionCode()) {
|
||||
showUpdateDialog(updateInfo)
|
||||
} else {
|
||||
hideUpdateProgress()
|
||||
Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -217,14 +333,40 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
)
|
||||
.setPositiveButton(R.string.download_update) { _, _ ->
|
||||
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
|
||||
downloadAndInstallUpdate(updateInfo)
|
||||
}
|
||||
.setNegativeButton(R.string.later, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadAndInstallUpdate(updateInfo: UpdateInfo) {
|
||||
lifecycleScope.launch {
|
||||
showUpdateProgress(getString(R.string.update_downloading))
|
||||
val apkFile = UpdateInstaller.downloadToTempFile(this@SettingsActivity, updateInfo)
|
||||
if (apkFile == null) {
|
||||
hideUpdateProgress()
|
||||
Toast.makeText(this@SettingsActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showUpdateProgress(getString(R.string.update_installing))
|
||||
val started = UpdateInstaller.installDownloadedApk(this@SettingsActivity, apkFile)
|
||||
if (!started) {
|
||||
hideUpdateProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUpdateProgress(message: String) {
|
||||
updateProgressText.visibility = View.VISIBLE
|
||||
updateProgressText.text = message
|
||||
}
|
||||
|
||||
private fun hideUpdateProgress() {
|
||||
updateProgressText.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun currentVersionCode(): Int {
|
||||
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
object IdentityManager {
|
||||
private const val PREFS_NAME = "nnnet_identity"
|
||||
private const val KEY_PRIVATE_KEY = "private_key_pkcs8"
|
||||
private const val KEY_PUBLIC_KEY = "public_key_x509"
|
||||
private const val RECOVERY_PREFIX = "NNNET-RECOVERY-1:"
|
||||
|
||||
fun getOrCreateKeyPair(context: Context): KeyPair {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val privateEncoded = prefs.getString(KEY_PRIVATE_KEY, null)
|
||||
val publicEncoded = prefs.getString(KEY_PUBLIC_KEY, null)
|
||||
if (!privateEncoded.isNullOrBlank() && !publicEncoded.isNullOrBlank()) {
|
||||
return KeyPair(decodePublicKey(publicEncoded), decodePrivateKey(privateEncoded))
|
||||
}
|
||||
|
||||
val generator = KeyPairGenerator.getInstance("EC")
|
||||
generator.initialize(256)
|
||||
val keyPair = generator.generateKeyPair()
|
||||
prefs.edit()
|
||||
.putString(KEY_PRIVATE_KEY, encodePrivateKey(keyPair.private))
|
||||
.putString(KEY_PUBLIC_KEY, encodePublicKey(keyPair.public))
|
||||
.apply()
|
||||
return keyPair
|
||||
}
|
||||
|
||||
fun exportRecoveryBundle(context: Context): String {
|
||||
val keyPair = getOrCreateKeyPair(context)
|
||||
val payload = JSONObject()
|
||||
.put("privateKey", encodePrivateKey(keyPair.private))
|
||||
.put("publicKey", encodePublicKey(keyPair.public))
|
||||
.toString()
|
||||
return RECOVERY_PREFIX + Base64.encodeToString(payload.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun importRecoveryBundle(context: Context, bundle: String) {
|
||||
require(bundle.startsWith(RECOVERY_PREFIX)) { "invalid recovery prefix" }
|
||||
val rawJson = String(
|
||||
Base64.decode(bundle.removePrefix(RECOVERY_PREFIX), Base64.NO_WRAP),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
val json = JSONObject(rawJson)
|
||||
val privateEncoded = json.getString("privateKey")
|
||||
val publicEncoded = json.getString("publicKey")
|
||||
decodePrivateKey(privateEncoded)
|
||||
decodePublicKey(publicEncoded)
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(KEY_PRIVATE_KEY, privateEncoded)
|
||||
.putString(KEY_PUBLIC_KEY, publicEncoded)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun encodePublicKey(publicKey: PublicKey): String =
|
||||
Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP)
|
||||
|
||||
fun decodePublicKey(encoded: String): PublicKey {
|
||||
val factory = KeyFactory.getInstance("EC")
|
||||
return factory.generatePublic(X509EncodedKeySpec(Base64.decode(encoded, Base64.NO_WRAP)))
|
||||
}
|
||||
|
||||
fun signClaim(privateKey: PrivateKey, canonicalPayload: String): String {
|
||||
val signature = Signature.getInstance("SHA256withECDSA")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(canonicalPayload.toByteArray(StandardCharsets.UTF_8))
|
||||
return Base64.encodeToString(signature.sign(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun verifyClaim(publicKey: PublicKey, canonicalPayload: String, signatureValue: String): Boolean {
|
||||
return runCatching {
|
||||
val signature = Signature.getInstance("SHA256withECDSA")
|
||||
signature.initVerify(publicKey)
|
||||
signature.update(canonicalPayload.toByteArray(StandardCharsets.UTF_8))
|
||||
signature.verify(Base64.decode(signatureValue, Base64.NO_WRAP))
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun canonicalProfileClaim(
|
||||
username: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
description: String,
|
||||
peerId: String,
|
||||
updatedAt: Long,
|
||||
leaseExpiresAt: Long
|
||||
): String {
|
||||
return listOf(
|
||||
username.trim().lowercase(),
|
||||
firstName.trim(),
|
||||
lastName.trim(),
|
||||
description.trim(),
|
||||
peerId.trim(),
|
||||
updatedAt.toString(),
|
||||
leaseExpiresAt.toString()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
private fun encodePrivateKey(privateKey: PrivateKey): String =
|
||||
Base64.encodeToString(privateKey.encoded, Base64.NO_WRAP)
|
||||
|
||||
private fun decodePrivateKey(encoded: String): PrivateKey {
|
||||
val factory = KeyFactory.getInstance("EC")
|
||||
return factory.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(encoded, Base64.NO_WRAP)))
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,15 @@ import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class],
|
||||
version = 2,
|
||||
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class MeshDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun outboundQueueDao(): OutboundQueueDao
|
||||
abstract fun profileDao(): ProfileDao
|
||||
abstract fun packetTraceDao(): PacketTraceDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import android.content.Context
|
||||
import pro.nnnteam.nnnet.mesh.MeshPacket
|
||||
import pro.nnnteam.nnnet.mesh.PacketType
|
||||
import java.util.UUID
|
||||
|
||||
class MeshRepository(
|
||||
private val context: Context,
|
||||
private val messageDao: MessageDao,
|
||||
private val queueDao: OutboundQueueDao,
|
||||
private val profileDao: ProfileDao
|
||||
private val profileDao: ProfileDao,
|
||||
private val packetTraceDao: PacketTraceDao
|
||||
) {
|
||||
suspend fun enqueueOutgoingMessage(
|
||||
senderId: String,
|
||||
@@ -70,7 +73,7 @@ class MeshRepository(
|
||||
messageId: String,
|
||||
attemptCount: Int,
|
||||
nextAttemptAt: Long,
|
||||
error: String? = null,
|
||||
@Suppress("UNUSED_PARAMETER") error: String? = null,
|
||||
now: Long = System.currentTimeMillis()
|
||||
) {
|
||||
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
|
||||
@@ -105,8 +108,36 @@ class MeshRepository(
|
||||
description: String,
|
||||
peerId: String = "",
|
||||
now: Long = System.currentTimeMillis()
|
||||
): ProfileEntity {
|
||||
): ProfileSaveResult {
|
||||
val normalizedUsername = normalizeUsername(username)
|
||||
if (!USERNAME_PATTERN.matches(normalizedUsername)) {
|
||||
return ProfileSaveResult.InvalidUsername
|
||||
}
|
||||
|
||||
val localProfile = profileDao.localProfile()
|
||||
val keyPair = IdentityManager.getOrCreateKeyPair(context)
|
||||
val publicKey = IdentityManager.encodePublicKey(keyPair.public)
|
||||
val existingClaim = profileDao.findByUsername(normalizedUsername)
|
||||
if (
|
||||
existingClaim != null &&
|
||||
existingClaim.publicKey != publicKey &&
|
||||
!existingClaim.isExpired(now)
|
||||
) {
|
||||
return ProfileSaveResult.UsernameOccupied(existingClaim)
|
||||
}
|
||||
|
||||
val leaseExpiresAt = now + USERNAME_LEASE_MS
|
||||
val canonical = IdentityManager.canonicalProfileClaim(
|
||||
username = normalizedUsername,
|
||||
firstName = firstName,
|
||||
lastName = lastName,
|
||||
description = description,
|
||||
peerId = peerId,
|
||||
updatedAt = now,
|
||||
leaseExpiresAt = leaseExpiresAt
|
||||
)
|
||||
val signature = IdentityManager.signClaim(keyPair.private, canonical)
|
||||
|
||||
profileDao.deleteLocalProfiles()
|
||||
val entity = ProfileEntity(
|
||||
username = normalizedUsername,
|
||||
@@ -115,74 +146,225 @@ class MeshRepository(
|
||||
description = description.trim(),
|
||||
peerId = peerId,
|
||||
updatedAt = now,
|
||||
leaseExpiresAt = leaseExpiresAt,
|
||||
lastSeenAt = now,
|
||||
publicKey = publicKey,
|
||||
signature = signature,
|
||||
isLocal = true
|
||||
)
|
||||
profileDao.upsert(entity)
|
||||
return entity
|
||||
|
||||
if (localProfile != null && localProfile.username != normalizedUsername) {
|
||||
expireIfOwned(localProfile.username, now)
|
||||
}
|
||||
|
||||
return ProfileSaveResult.Saved(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 refreshLocalClaim(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
|
||||
if (peerId.isBlank()) return null
|
||||
val localProfile = profileDao.localProfile() ?: return null
|
||||
val leaseExpiresAt = now + USERNAME_LEASE_MS
|
||||
val canonical = IdentityManager.canonicalProfileClaim(
|
||||
username = localProfile.username,
|
||||
firstName = localProfile.firstName,
|
||||
lastName = localProfile.lastName,
|
||||
description = localProfile.description,
|
||||
peerId = peerId,
|
||||
updatedAt = now,
|
||||
leaseExpiresAt = leaseExpiresAt
|
||||
)
|
||||
val keyPair = IdentityManager.getOrCreateKeyPair(context)
|
||||
val signature = IdentityManager.signClaim(keyPair.private, canonical)
|
||||
profileDao.updateLocalClaim(peerId, now, leaseExpiresAt, now, signature)
|
||||
return profileDao.localProfile()
|
||||
}
|
||||
|
||||
suspend fun upsertRemoteProfile(
|
||||
payload: ProfilePayload,
|
||||
peerId: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): ProfileEntity? {
|
||||
): RemoteProfileResult {
|
||||
val normalizedUsername = normalizeUsername(payload.username)
|
||||
if (normalizedUsername.isBlank()) return null
|
||||
if (normalizedUsername.isBlank()) return RemoteProfileResult.Invalid
|
||||
if (!USERNAME_PATTERN.matches(normalizedUsername)) return RemoteProfileResult.Invalid
|
||||
if (payload.leaseExpiresAt < now) return RemoteProfileResult.Expired
|
||||
|
||||
val canonical = IdentityManager.canonicalProfileClaim(
|
||||
username = normalizedUsername,
|
||||
firstName = payload.firstName,
|
||||
lastName = payload.lastName,
|
||||
description = payload.description,
|
||||
peerId = peerId,
|
||||
updatedAt = payload.updatedAt,
|
||||
leaseExpiresAt = payload.leaseExpiresAt
|
||||
)
|
||||
val publicKey = runCatching { IdentityManager.decodePublicKey(payload.publicKey) }.getOrNull()
|
||||
?: return RemoteProfileResult.Invalid
|
||||
val valid = IdentityManager.verifyClaim(publicKey, canonical, payload.signature)
|
||||
if (!valid) return RemoteProfileResult.Invalid
|
||||
|
||||
val localProfile = profileDao.localProfile()
|
||||
if (localProfile?.username == normalizedUsername) {
|
||||
return localProfile
|
||||
if (localProfile?.username == normalizedUsername && localProfile.publicKey != payload.publicKey) {
|
||||
return RemoteProfileResult.Rejected(localProfile)
|
||||
}
|
||||
|
||||
val existing = profileDao.findByUsername(normalizedUsername)
|
||||
if (existing != null && existing.publicKey != payload.publicKey && !existing.isExpired(now)) {
|
||||
return RemoteProfileResult.Rejected(existing)
|
||||
}
|
||||
|
||||
val existingByKey = profileDao.findByPublicKey(payload.publicKey)
|
||||
if (existingByKey != null && existingByKey.username != normalizedUsername) {
|
||||
profileDao.upsert(existingByKey.copy(leaseExpiresAt = now - 1))
|
||||
}
|
||||
|
||||
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),
|
||||
updatedAt = payload.updatedAt,
|
||||
leaseExpiresAt = payload.leaseExpiresAt,
|
||||
lastSeenAt = now,
|
||||
publicKey = payload.publicKey,
|
||||
signature = payload.signature,
|
||||
isLocal = false
|
||||
)
|
||||
profileDao.upsert(entity)
|
||||
return entity
|
||||
return RemoteProfileResult.Stored(entity)
|
||||
}
|
||||
|
||||
suspend fun profileByUsername(username: String): ProfileEntity? {
|
||||
return profileDao.findByUsername(normalizeUsername(username))
|
||||
suspend fun profileByUsername(username: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
|
||||
val entity = profileDao.findByUsername(normalizeUsername(username)) ?: return null
|
||||
return if (entity.isExpired(now)) null else entity
|
||||
}
|
||||
|
||||
suspend fun profileByPeerId(peerId: String): ProfileEntity? {
|
||||
suspend fun profileByPeerId(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
|
||||
if (peerId.isBlank()) return null
|
||||
return profileDao.findByPeerId(peerId)
|
||||
val entity = profileDao.findByPeerId(peerId) ?: return null
|
||||
return if (entity.isExpired(now)) null else entity
|
||||
}
|
||||
|
||||
suspend fun searchProfiles(query: String, limit: Int = 20): List<ProfileEntity> {
|
||||
suspend fun searchProfiles(query: String, now: Long = System.currentTimeMillis(), 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 {
|
||||
val lookup = trimmed.removePrefix("@").lowercase()
|
||||
val exact = profileByUsername(lookup, now)
|
||||
val fuzzy = profileDao.search("%$trimmed%", limit * 2)
|
||||
.filterNot { it.isExpired(now) }
|
||||
val combined = buildList<ProfileEntity> {
|
||||
if (exact != null) add(exact)
|
||||
fuzzy.forEach { candidate ->
|
||||
if (none { it.username == candidate.username }) {
|
||||
if (this.none { existing -> existing.username == candidate.username && existing.peerId == candidate.peerId }) {
|
||||
add(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return combined.sortedWith(
|
||||
compareBy<ProfileEntity> {
|
||||
val fullName = listOf(it.firstName, it.lastName).joinToString(" ").trim().lowercase()
|
||||
when {
|
||||
it.username == lookup -> 0
|
||||
it.peerId.equals(trimmed, ignoreCase = true) -> 1
|
||||
fullName == trimmed.lowercase() -> 2
|
||||
it.firstName.equals(trimmed, ignoreCase = true) || it.lastName.equals(trimmed, ignoreCase = true) -> 3
|
||||
else -> 4
|
||||
}
|
||||
}.thenByDescending { it.updatedAt }
|
||||
).take(limit)
|
||||
}
|
||||
|
||||
suspend fun queuedCount(): Int = queueDao.count()
|
||||
|
||||
private fun normalizeUsername(value: String): String = value.trim().lowercase()
|
||||
suspend fun recordPacketTrace(
|
||||
traceDirection: String,
|
||||
packet: MeshPacket,
|
||||
relatedPeerId: String = "",
|
||||
now: Long = System.currentTimeMillis()
|
||||
) {
|
||||
packetTraceDao.insert(
|
||||
PacketTraceEntity(
|
||||
traceDirection = traceDirection,
|
||||
packetType = packet.type.name,
|
||||
messageId = packet.messageId,
|
||||
senderId = packet.senderId,
|
||||
targetId = packet.targetId,
|
||||
relatedPeerId = relatedPeerId,
|
||||
payloadPreview = packet.payload.take(120),
|
||||
createdAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun recentPacketTraces(direction: String, limit: Int = 50): List<PacketTraceEntity> {
|
||||
return packetTraceDao.recentByDirection(direction, limit)
|
||||
}
|
||||
|
||||
suspend fun recentPacketSummaries(direction: String, limit: Int = 50): List<PacketTraceSummary> {
|
||||
return packetTraceDao.recentByDirection(direction, limit).map { trace ->
|
||||
PacketTraceSummary(
|
||||
title = "${directionLabel(trace.traceDirection)} · ${trace.packetType}",
|
||||
subtitle = "${trace.senderId} → ${trace.targetId}",
|
||||
meta = buildString {
|
||||
if (trace.relatedPeerId.isNotBlank()) {
|
||||
append("через ${trace.relatedPeerId} · ")
|
||||
}
|
||||
append(trace.payloadPreview)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mapNodes(limit: Int = 24, now: Long = System.currentTimeMillis()): List<ProfileEntity> {
|
||||
val peerIds = packetTraceDao.recentPeerIds(limit)
|
||||
return peerIds.mapNotNull { peerId ->
|
||||
val existing = profileDao.findByPeerId(peerId)
|
||||
when {
|
||||
existing == null -> ProfileEntity(
|
||||
username = peerId.lowercase(),
|
||||
firstName = "",
|
||||
lastName = "",
|
||||
description = "",
|
||||
peerId = peerId,
|
||||
updatedAt = 0L,
|
||||
leaseExpiresAt = now + USERNAME_LEASE_MS,
|
||||
lastSeenAt = 0L,
|
||||
publicKey = "",
|
||||
signature = "",
|
||||
isLocal = false
|
||||
)
|
||||
existing.isExpired(now) -> null
|
||||
else -> existing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportRecoveryBundle(): String = IdentityManager.exportRecoveryBundle(context)
|
||||
|
||||
fun importRecoveryBundle(bundle: String) {
|
||||
IdentityManager.importRecoveryBundle(context, bundle)
|
||||
}
|
||||
|
||||
private suspend fun expireIfOwned(username: String, now: Long) {
|
||||
val existing = profileDao.findByUsername(username) ?: return
|
||||
if (existing.isLocal) return
|
||||
if (existing.leaseExpiresAt < now) {
|
||||
profileDao.upsert(existing.copy(leaseExpiresAt = now - 1))
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeUsername(value: String): String = value.trim().removePrefix("@").lowercase()
|
||||
|
||||
private fun directionLabel(direction: String): String = when (direction) {
|
||||
TRACE_OUTGOING -> "Исходящий"
|
||||
TRACE_INCOMING -> "Входящий"
|
||||
TRACE_RELAY -> "Транзит"
|
||||
else -> direction
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STATUS_QUEUED = "queued"
|
||||
@@ -192,7 +374,25 @@ class MeshRepository(
|
||||
|
||||
const val DIRECTION_INCOMING = "incoming"
|
||||
const val DIRECTION_OUTGOING = "outgoing"
|
||||
const val TRACE_OUTGOING = "outgoing"
|
||||
const val TRACE_INCOMING = "incoming"
|
||||
const val TRACE_RELAY = "relay"
|
||||
const val USERNAME_LEASE_MS = 14L * 24L * 60L * 60L * 1000L
|
||||
val USERNAME_PATTERN = Regex("^[a-z0-9_]{4,32}$")
|
||||
|
||||
private const val DEFAULT_MAX_ATTEMPTS = 5
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ProfileSaveResult {
|
||||
data class Saved(val profile: ProfileEntity) : ProfileSaveResult
|
||||
data class UsernameOccupied(val profile: ProfileEntity) : ProfileSaveResult
|
||||
data object InvalidUsername : ProfileSaveResult
|
||||
}
|
||||
|
||||
sealed interface RemoteProfileResult {
|
||||
data class Stored(val profile: ProfileEntity) : RemoteProfileResult
|
||||
data class Rejected(val conflictingProfile: ProfileEntity) : RemoteProfileResult
|
||||
data object Expired : RemoteProfileResult
|
||||
data object Invalid : RemoteProfileResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface PacketTraceDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(trace: PacketTraceEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM packet_traces
|
||||
WHERE traceDirection = :direction
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recentByDirection(direction: String, limit: Int): List<PacketTraceEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM packet_traces
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recent(limit: Int): List<PacketTraceEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT relatedPeerId FROM packet_traces
|
||||
WHERE relatedPeerId != ''
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recentPeerIds(limit: Int): List<String>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "packet_traces")
|
||||
data class PacketTraceEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val traceDirection: String,
|
||||
val packetType: String,
|
||||
val messageId: String,
|
||||
val senderId: String,
|
||||
val targetId: String,
|
||||
val relatedPeerId: String,
|
||||
val payloadPreview: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
data class PacketTraceSummary(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val meta: String
|
||||
)
|
||||
@@ -22,16 +22,43 @@ interface ProfileDao {
|
||||
@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 publicKey = :publicKey ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
|
||||
suspend fun findByPublicKey(publicKey: String): ProfileEntity?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM profiles
|
||||
WHERE username LIKE :query OR firstName LIKE :query OR lastName LIKE :query
|
||||
WHERE username LIKE :query
|
||||
OR firstName LIKE :query
|
||||
OR lastName LIKE :query
|
||||
OR (firstName || ' ' || lastName) LIKE :query
|
||||
OR (lastName || ' ' || firstName) LIKE :query
|
||||
OR peerId 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)
|
||||
@Query(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET peerId = :peerId,
|
||||
updatedAt = :updatedAt,
|
||||
leaseExpiresAt = :leaseExpiresAt,
|
||||
lastSeenAt = :lastSeenAt,
|
||||
signature = :signature
|
||||
WHERE isLocal = 1
|
||||
"""
|
||||
)
|
||||
suspend fun updateLocalClaim(
|
||||
peerId: String,
|
||||
updatedAt: Long,
|
||||
leaseExpiresAt: Long,
|
||||
lastSeenAt: Long,
|
||||
signature: String
|
||||
)
|
||||
|
||||
@Query("SELECT * FROM profiles WHERE isLocal = 0 AND leaseExpiresAt >= :now ORDER BY updatedAt DESC")
|
||||
suspend fun activeRemoteProfiles(now: Long): List<ProfileEntity>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ data class ProfileEntity(
|
||||
val description: String,
|
||||
val peerId: String,
|
||||
val updatedAt: Long,
|
||||
val leaseExpiresAt: Long,
|
||||
val lastSeenAt: Long,
|
||||
val publicKey: String,
|
||||
val signature: String,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
fun displayName(): String {
|
||||
@@ -23,10 +26,15 @@ data class ProfileEntity(
|
||||
}
|
||||
|
||||
fun metaLine(): String {
|
||||
return if (peerId.isBlank()) {
|
||||
"@$username"
|
||||
} else {
|
||||
"@$username · $peerId"
|
||||
return buildString {
|
||||
append("@")
|
||||
append(username)
|
||||
if (peerId.isNotBlank()) {
|
||||
append(" · ")
|
||||
append(peerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isExpired(now: Long = System.currentTimeMillis()): Boolean = !isLocal && leaseExpiresAt < now
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ data class ProfilePayload(
|
||||
val lastName: String,
|
||||
val username: String,
|
||||
val description: String,
|
||||
val updatedAt: Long
|
||||
val updatedAt: Long,
|
||||
val leaseExpiresAt: Long,
|
||||
val publicKey: String,
|
||||
val signature: String
|
||||
) {
|
||||
fun normalizedUsername(): String = username.trim().lowercase()
|
||||
}
|
||||
@@ -20,6 +23,9 @@ object ProfilePayloadCodec {
|
||||
.put("username", payload.username)
|
||||
.put("description", payload.description)
|
||||
.put("updatedAt", payload.updatedAt)
|
||||
.put("leaseExpiresAt", payload.leaseExpiresAt)
|
||||
.put("publicKey", payload.publicKey)
|
||||
.put("signature", payload.signature)
|
||||
.toString()
|
||||
}
|
||||
|
||||
@@ -30,7 +36,10 @@ object ProfilePayloadCodec {
|
||||
lastName = json.optString("lastName", "").trim(),
|
||||
username = json.getString("username").trim(),
|
||||
description = json.optString("description", "").trim(),
|
||||
updatedAt = json.optLong("updatedAt", System.currentTimeMillis())
|
||||
updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
|
||||
leaseExpiresAt = json.optLong("leaseExpiresAt", 0L),
|
||||
publicKey = json.getString("publicKey"),
|
||||
signature = json.getString("signature")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ class BleMeshManager(
|
||||
private val onAckReceived: (String) -> Unit = {},
|
||||
private val onMessageReceived: (MeshPacket) -> Unit = {},
|
||||
private val onProfileReceived: (MeshPacket) -> Unit = {},
|
||||
private val onPacketOutgoing: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onPacketIncoming: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onPacketRelay: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onError: (String) -> Unit = {},
|
||||
private val onLog: (String) -> Unit = {},
|
||||
private val seenPacketCache: SeenPacketCache = SeenPacketCache()
|
||||
@@ -251,24 +254,29 @@ class BleMeshManager(
|
||||
MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}")
|
||||
MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}")
|
||||
is MeshAction.ConsumeAck -> {
|
||||
onPacketIncoming(packet, packet.senderId)
|
||||
onAckReceived(action.messageId)
|
||||
log("ACK обработан: ${action.messageId}")
|
||||
}
|
||||
is MeshAction.ConsumePresence -> {
|
||||
onPacketIncoming(packet, packet.senderId)
|
||||
onPeerDiscovered(action.senderId)
|
||||
onStatusChanged("Устройство ${action.senderId} рядом")
|
||||
log("Сигнал присутствия обработан от ${action.senderId}")
|
||||
}
|
||||
is MeshAction.DeliverMessage -> {
|
||||
onPacketIncoming(action.packet, action.packet.senderId)
|
||||
onMessageReceived(action.packet)
|
||||
onStatusChanged("Новое сообщение от ${action.packet.senderId}")
|
||||
sendAck(action.packet)
|
||||
}
|
||||
is MeshAction.CacheProfile -> {
|
||||
onPacketRelay(action.packet, action.packet.senderId)
|
||||
onProfileReceived(action.packet)
|
||||
broadcastIfAlive(action.packetToRelay)
|
||||
}
|
||||
is MeshAction.Relay -> {
|
||||
onPacketRelay(action.packetToRelay, action.packetToRelay.senderId)
|
||||
log("Ретрансляция пакета ${action.packetToRelay.messageId}")
|
||||
broadcastIfAlive(action.packetToRelay)
|
||||
}
|
||||
@@ -365,6 +373,7 @@ class BleMeshManager(
|
||||
type = PacketType.PRESENCE,
|
||||
payload = "presence:$localNodeId"
|
||||
)
|
||||
onPacketOutgoing(packet, gatt.device.address ?: "")
|
||||
writePacket(gatt, packet)
|
||||
}
|
||||
|
||||
@@ -375,6 +384,7 @@ class BleMeshManager(
|
||||
type = PacketType.ACK,
|
||||
payload = packet.messageId
|
||||
)
|
||||
onPacketOutgoing(ack, packet.senderId)
|
||||
broadcastPacket(ack)
|
||||
}
|
||||
|
||||
@@ -387,6 +397,7 @@ class BleMeshManager(
|
||||
}
|
||||
|
||||
fun sendPacket(packet: MeshPacket): Boolean {
|
||||
onPacketOutgoing(packet, packet.targetId)
|
||||
val directedGatt = activeConnections[packet.targetId]
|
||||
return if (directedGatt != null) {
|
||||
writePacket(directedGatt, packet)
|
||||
|
||||
@@ -31,7 +31,13 @@ class MeshForegroundService : Service() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao(), database.profileDao())
|
||||
repository = MeshRepository(
|
||||
applicationContext,
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
bleMeshManager = BleMeshManager(
|
||||
context = applicationContext,
|
||||
onPeerDiscovered = { address ->
|
||||
@@ -63,13 +69,33 @@ class MeshForegroundService : Service() {
|
||||
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)
|
||||
when (val result = repository.upsertRemoteProfile(payload, packet.senderId)) {
|
||||
is pro.nnnteam.nnnet.data.RemoteProfileResult.Stored -> {
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${result.profile.username}")
|
||||
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, result.profile.username)
|
||||
}
|
||||
is pro.nnnteam.nnnet.data.RemoteProfileResult.Rejected -> {
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Отклонён захват username @${result.conflictingProfile.username}")
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
onPacketOutgoing = { packet, relatedPeerId ->
|
||||
serviceScope.launch {
|
||||
repository.recordPacketTrace(MeshRepository.TRACE_OUTGOING, packet, relatedPeerId)
|
||||
}
|
||||
},
|
||||
onPacketIncoming = { packet, relatedPeerId ->
|
||||
serviceScope.launch {
|
||||
repository.recordPacketTrace(MeshRepository.TRACE_INCOMING, packet, relatedPeerId)
|
||||
}
|
||||
},
|
||||
onPacketRelay = { packet, relatedPeerId ->
|
||||
serviceScope.launch {
|
||||
repository.recordPacketTrace(MeshRepository.TRACE_RELAY, packet, relatedPeerId)
|
||||
}
|
||||
},
|
||||
onError = { message ->
|
||||
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
|
||||
@@ -111,7 +137,6 @@ class MeshForegroundService : Service() {
|
||||
queueProcessor.start()
|
||||
queueProcessor.poke()
|
||||
serviceScope.launch {
|
||||
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
||||
publishLocalProfile(force = true)
|
||||
}
|
||||
}
|
||||
@@ -122,14 +147,16 @@ class MeshForegroundService : Service() {
|
||||
return
|
||||
}
|
||||
|
||||
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
||||
val localProfile = repository.localProfile() ?: return
|
||||
val localProfile = repository.refreshLocalClaim(bleMeshManager.nodeId) ?: return
|
||||
val payload = ProfilePayload(
|
||||
firstName = localProfile.firstName,
|
||||
lastName = localProfile.lastName,
|
||||
username = localProfile.username,
|
||||
description = localProfile.description,
|
||||
updatedAt = localProfile.updatedAt
|
||||
updatedAt = localProfile.updatedAt,
|
||||
leaseExpiresAt = localProfile.leaseExpiresAt,
|
||||
publicKey = localProfile.publicKey,
|
||||
signature = localProfile.signature
|
||||
)
|
||||
val sent = bleMeshManager.sendPacket(
|
||||
MeshPacket(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package pro.nnnteam.nnnet.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
class NetworkMapView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#4C9EEB")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val selfPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#33A56E")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#1E2B37")
|
||||
textSize = 34f
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#A7B8C7")
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
private var nodes: List<MapNodeUi> = emptyList()
|
||||
|
||||
fun submitNodes(items: List<MapNodeUi>) {
|
||||
nodes = items
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val radius = min(width, height) * 0.32f
|
||||
val selfNode = nodes.first()
|
||||
|
||||
canvas.drawCircle(cx, cy, 52f, selfPaint)
|
||||
canvas.drawText(selfNode.label, cx, cy + 86f, textPaint)
|
||||
|
||||
val others = nodes.drop(1)
|
||||
if (others.isEmpty()) return
|
||||
|
||||
others.forEachIndexed { index, node ->
|
||||
val angle = (2 * PI * index / others.size) - PI / 2
|
||||
val nx = cx + (radius * cos(angle)).toFloat()
|
||||
val ny = cy + (radius * sin(angle)).toFloat()
|
||||
canvas.drawLine(cx, cy, nx, ny, linePaint)
|
||||
canvas.drawCircle(nx, ny, 42f, nodePaint)
|
||||
canvas.drawText(node.label, nx, ny + 76f, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MapNodeUi(
|
||||
val label: String,
|
||||
val peerId: String,
|
||||
val isSelf: Boolean
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package pro.nnnteam.nnnet.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import pro.nnnteam.nnnet.R
|
||||
import pro.nnnteam.nnnet.data.PacketTraceSummary
|
||||
|
||||
class PacketTraceAdapter(
|
||||
context: Context,
|
||||
private val items: MutableList<PacketTraceSummary>
|
||||
) : BaseAdapter() {
|
||||
private val inflater = LayoutInflater.from(context)
|
||||
|
||||
override fun getCount(): Int = items.size
|
||||
|
||||
override fun getItem(position: Int): PacketTraceSummary = items[position]
|
||||
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: inflater.inflate(R.layout.item_packet_trace, parent, false)
|
||||
val item = getItem(position)
|
||||
view.findViewById<TextView>(R.id.traceTitleText).text = item.title
|
||||
view.findViewById<TextView>(R.id.traceSubtitleText).text = item.subtitle
|
||||
view.findViewById<TextView>(R.id.traceMetaText).text = item.meta
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pro.nnnteam.nnnet.update
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import pro.nnnteam.nnnet.R
|
||||
import pro.nnnteam.nnnet.mesh.MeshForegroundService
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object UpdateInstaller {
|
||||
suspend fun downloadToTempFile(context: Context, updateInfo: UpdateInfo): File? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val updatesDir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val apkFile = File(updatesDir, "nnnet-update-${updateInfo.versionName}.apk")
|
||||
val connection = URL(UpdateManager.buildDownloadUrl(updateInfo.apkPath)).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.inputStream.use { input ->
|
||||
apkFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
apkFile
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun installDownloadedApk(context: Context, apkFile: File, stopMesh: Boolean = true): Boolean {
|
||||
if (!apkFile.exists()) return false
|
||||
if (stopMesh) {
|
||||
MeshForegroundService.stop(context)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
|
||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Toast.makeText(context, R.string.allow_unknown_apps, Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
return try {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, R.string.installer_not_found, Toast.LENGTH_SHORT).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
24
android/app/src/main/res/drawable/ic_nnnet_app.xml
Normal file
24
android/app/src/main/res/drawable/ic_nnnet_app.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#4C9EEB"
|
||||
android:pathData="M18,10h72c4.4,0 8,3.6 8,8v72c0,4.4 -3.6,8 -8,8H18c-4.4,0 -8,-3.6 -8,-8V18c0,-4.4 3.6,-8 8,-8z" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="8"
|
||||
android:pathData="M30,76 L30,34 L54,62 L78,32 L78,76" />
|
||||
<path
|
||||
android:fillColor="#A8D6FA"
|
||||
android:pathData="M24,70a8,8 0 1,1 16,0a8,8 0 1,1 -16,0" />
|
||||
<path
|
||||
android:fillColor="#A8D6FA"
|
||||
android:pathData="M46,56a8,8 0 1,1 16,0a8,8 0 1,1 -16,0" />
|
||||
<path
|
||||
android:fillColor="#A8D6FA"
|
||||
android:pathData="M70,26a8,8 0 1,1 16,0a8,8 0 1,1 -16,0" />
|
||||
</vector>
|
||||
75
android/app/src/main/res/layout/activity_packet_log.xml
Normal file
75
android/app/src/main/res/layout/activity_packet_log.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/screen_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/top_bar_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/back"
|
||||
android:src="@android:drawable/ic_media_previous"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/packet_log_title"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/outgoingButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/outgoing_packets" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/incomingButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/incoming_packets" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/relayButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/relay_packets" />
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/packetListView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:divider="@color/chat_divider"
|
||||
android:dividerHeight="1dp"
|
||||
android:listSelector="@android:color/transparent" />
|
||||
</LinearLayout>
|
||||
50
android/app/src/main/res/layout/activity_packet_map.xml
Normal file
50
android/app/src/main/res/layout/activity_packet_map.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/screen_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/top_bar_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/back"
|
||||
android:src="@android:drawable/ic_media_previous"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/map_mode_title"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapMetaText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:textColor="@color/secondary_text" />
|
||||
|
||||
<pro.nnnteam.nnnet.ui.NetworkMapView
|
||||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
@@ -137,6 +137,16 @@
|
||||
android:text="@string/find_profile"
|
||||
app:cornerRadius="18dp" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/searchResultsView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_settings_card"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="8dp"
|
||||
android:padding="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/profileResultCard"
|
||||
android:layout_width="match_parent"
|
||||
@@ -180,6 +190,31 @@
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/diagnostics_title"
|
||||
android:textColor="@color/primary_text"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openMapButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/map_mode_title"
|
||||
app:cornerRadius="18dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/openPacketLogButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/packet_log_title"
|
||||
app:cornerRadius="18dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/versionText"
|
||||
android:layout_width="match_parent"
|
||||
@@ -208,6 +243,52 @@
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/check_updates"
|
||||
app:cornerRadius="18dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/updateProgressText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="@color/secondary_text"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/recovery_section_title"
|
||||
android:textColor="@color/primary_text"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/recoveryInput"
|
||||
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/recovery_hint"
|
||||
android:minLines="4"
|
||||
android:padding="16dp"
|
||||
android:textColor="@color/primary_text"
|
||||
android:textColorHint="@color/secondary_text" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/showRecoveryButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/show_recovery"
|
||||
app:cornerRadius="18dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/importRecoveryButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/import_recovery"
|
||||
app:cornerRadius="18dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
|
||||
31
android/app/src/main/res/layout/item_packet_trace.xml
Normal file
31
android/app/src/main/res/layout/item_packet_trace.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceTitleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/primary_text"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceSubtitleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/secondary_text"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceMetaText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/accent_blue"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/menu_map"
|
||||
android:title="@string/map_mode_title" />
|
||||
<item
|
||||
android:id="@+id/menu_packets"
|
||||
android:title="@string/packet_log_title" />
|
||||
<item
|
||||
android:id="@+id/menu_settings"
|
||||
android:title="@string/settings_title" />
|
||||
|
||||
@@ -18,12 +18,18 @@
|
||||
<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="peer_id_required">Введите username, имя или peerId</string>
|
||||
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
|
||||
<string name="update_check_failed">Не удалось проверить обновления</string>
|
||||
<string name="update_download_failed">Не удалось скачать обновление</string>
|
||||
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
|
||||
<string name="update_available_message">Доступна версия %1$s.</string>
|
||||
<string name="download_update">Скачать обновление</string>
|
||||
<string name="update_checking">Проверяем версию…</string>
|
||||
<string name="update_downloading">Скачиваем APK во временный каталог…</string>
|
||||
<string name="update_installing">Останавливаем сеть и запускаем установку…</string>
|
||||
<string name="allow_unknown_apps">Разрешите установку из этого приложения, затем повторите обновление.</string>
|
||||
<string name="installer_not_found">Системный установщик не найден</string>
|
||||
<string name="later">Позже</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="no_messages">Сообщений пока нет. Напишите первым.</string>
|
||||
@@ -43,12 +49,35 @@
|
||||
<string name="profile_description">Описание</string>
|
||||
<string name="save_profile">Сохранить профиль</string>
|
||||
<string name="search_profile_title">Найти профиль</string>
|
||||
<string name="search_username">Введите username</string>
|
||||
<string name="search_username">Введите username, имя или peerId</string>
|
||||
<string name="find_profile">Найти</string>
|
||||
<string name="enter_username_to_search">Введите username для поиска</string>
|
||||
<string name="enter_username_to_search">Введите username, имя или peerId для поиска</string>
|
||||
<string name="username_required">Username обязателен</string>
|
||||
<string name="profile_saved">Профиль сохранён</string>
|
||||
<string name="username_taken">Этот username уже занят активным владельцем</string>
|
||||
<string name="username_invalid">Username должен содержать 4-32 символа: латиница, цифры, _</string>
|
||||
<string name="no_profile_description">Описание не указано</string>
|
||||
<string name="peer_id_unknown">peerId пока неизвестен</string>
|
||||
<string name="peer_id_value">peerId: %1$s</string>
|
||||
<string name="recovery_section_title">Перенос профиля</string>
|
||||
<string name="show_recovery">Показать код переноса</string>
|
||||
<string name="import_recovery">Импортировать код переноса</string>
|
||||
<string name="recovery_hint">Код переноса профиля</string>
|
||||
<string name="recovery_shown">Код переноса сформирован</string>
|
||||
<string name="recovery_required">Вставьте код переноса</string>
|
||||
<string name="recovery_imported">Код переноса импортирован</string>
|
||||
<string name="recovery_invalid">Код переноса повреждён или неверен</string>
|
||||
<string name="recovery_dialog_title">Код переноса</string>
|
||||
<string name="recovery_dialog_message">Сохраните этот код в надёжном месте. С его помощью можно вернуть username и профиль на новом телефоне.</string>
|
||||
<string name="ok">ОК</string>
|
||||
<string name="copy_code">Копировать код</string>
|
||||
<string name="copied">Скопировано</string>
|
||||
<string name="diagnostics_title">Диагностика сети</string>
|
||||
<string name="map_mode_title">Режим карты</string>
|
||||
<string name="packet_log_title">Журнал пакетов</string>
|
||||
<string name="outgoing_packets">Исходящие</string>
|
||||
<string name="incoming_packets">Входящие</string>
|
||||
<string name="relay_packets">Транзит</string>
|
||||
<string name="you_label">Вы</string>
|
||||
<string name="map_mode_hint">Показано устройств: %1$d. Карта строится по сетевым связям и не является GPS-позицией.</string>
|
||||
</resources>
|
||||
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="update_cache"
|
||||
path="updates/" />
|
||||
</paths>
|
||||
@@ -5,23 +5,30 @@
|
||||
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
|
||||
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
|
||||
- Storage Layer: Room для локального хранения сообщений, очереди и профилей.
|
||||
- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`.
|
||||
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
||||
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
||||
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`.
|
||||
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети, разрешение `username <-> peerId`, lease на 14 дней и recovery bundle для переноса идентичности.
|
||||
|
||||
## Пользовательский сценарий
|
||||
- Главный экран показывает список чатов в стиле Telegram.
|
||||
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
|
||||
- Слева в шапке показывается общее количество известных устройств в mesh.
|
||||
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||
- Отправка сообщений доступна только из экрана конкретного диалога.
|
||||
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
|
||||
- В настройках пользователь редактирует свой профиль, ищет другие профили по `username`, имени, фамилии, полному имени и `peerId`, а также может экспортировать или импортировать recovery bundle.
|
||||
- В настройках доступны режим карты сети и экран журнала пакетов.
|
||||
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
|
||||
|
||||
## Топология сети
|
||||
- Выделенный сервер или хост для работы mesh не нужен.
|
||||
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
||||
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
||||
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
|
||||
- Каждый профильный пакет содержит подписанный claim владельца: `username`, имя, описание, `peerId`, `updatedAt`, `leaseExpiresAt`, `publicKey`, `signature`.
|
||||
- Захват чужого `username` блокируется проверкой подписи и активного lease. Если владелец не появляется в сети 14 дней, claim считается протухшим и `username` можно занять заново.
|
||||
- Перенос на новый телефон делается через recovery bundle с ключами владельца: после импорта тот же пользователь может опубликовать прежний `username` уже с новым `peerId`.
|
||||
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
|
||||
|
||||
## Сетевой пакет (черновик)
|
||||
```json
|
||||
@@ -38,5 +45,5 @@
|
||||
|
||||
## Ближайшие шаги
|
||||
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
||||
2. Ввести шифрование payload и подпись пакетов.
|
||||
2. Ввести шифрование payload и подпись уже не только профильных, но и message-пакетов.
|
||||
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.
|
||||
|
||||
7
website/assets/img/icon.svg
Normal file
7
website/assets/img/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="108" height="108" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="10" width="88" height="88" rx="8" fill="#4C9EEB"/>
|
||||
<path d="M30 76V34L54 62L78 32V76" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="70" r="8" fill="#A8D6FA"/>
|
||||
<circle cx="54" cy="56" r="8" fill="#A8D6FA"/>
|
||||
<circle cx="78" cy="34" r="8" fill="#A8D6FA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
@@ -1,4 +1,4 @@
|
||||
- Добавлены профили пользователей с именем, фамилией, username и описанием.
|
||||
- Реализован распределённый каталог профилей: узлы кэшируют профили из mesh-сети и позволяют находить пользователя по username.
|
||||
- Список чатов и экран диалога теперь показывают имя и username, если профиль уже известен.
|
||||
- В настройках появился экран редактирования профиля и поиск профиля с получением peerId.
|
||||
- Добавлены подписанные username-claims с защитой от захвата чужого имени.
|
||||
- Добавлен recovery bundle для переноса профиля и username на новый телефон.
|
||||
- Поиск профилей теперь работает по username, имени, фамилии, полному имени и peerId.
|
||||
- В UI показывается связь peerId и @username через локальный mesh-каталог.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NNNet</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/icon.svg" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
|
||||
<link href="assets/css/styles.css" rel="stylesheet" />
|
||||
@@ -42,7 +43,10 @@
|
||||
<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 class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-person-badge fs-2"></i><h5 class="mt-2">Профили и поиск</h5><p class="mb-0">Поиск по @username, имени, фамилии и peerId. Связка username ↔ peerId хранится в mesh-каталоге.</p></div></div>
|
||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-key fs-2"></i><h5 class="mt-2">Перенос профиля</h5><p class="mb-0">Recovery bundle позволяет вернуть профиль и username на новом телефоне даже без старого устройства.</p></div></div>
|
||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-shield-check fs-2"></i><h5 class="mt-2">Защита username</h5><p class="mb-0">Профиль подписывается владельцем. Активный username освобождается только после 14 дней неактивности.</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user