Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca8a5cb86 | ||
|
|
6c715477b4 | ||
|
|
0d40a0ae2a |
12
README.md
12
README.md
@@ -14,8 +14,11 @@
|
|||||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
||||||
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
|
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
|
||||||
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
|
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`, имени, фамилии, полному имени и `peerId`.
|
||||||
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
|
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`, и наоборот по `peerId` в UI показывается связанный `@username`, если claim уже известен.
|
||||||
|
- Username оформлен как подписанный lease-claim на 14 дней: если владелец не появляется в сети 2 недели, username снова становится свободным.
|
||||||
|
- Добавлен recovery bundle для переноса профиля и права на username на новый телефон даже без доступа к старому устройству.
|
||||||
|
- Добавлена защита от захвата чужого `username`: активный claim другого владельца не принимается, а смена `peerId` без подписи владельца не даёт забрать профиль.
|
||||||
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
|
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
|
||||||
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
|
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
|
||||||
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
||||||
@@ -88,6 +91,10 @@
|
|||||||
- [x] Подключить Room и базовую схему хранения.
|
- [x] Подключить Room и базовую схему хранения.
|
||||||
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
|
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
|
||||||
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
|
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
|
||||||
|
- [x] Добавить поиск профилей по имени, фамилии, полному имени и `peerId`.
|
||||||
|
- [x] Добавить lease-механику для `username` с автоматическим освобождением через 14 дней неактивности.
|
||||||
|
- [x] Добавить перенос профиля на новый телефон через recovery bundle.
|
||||||
|
- [x] Добавить защиту от захвата чужого `username` и подмены `peerId` в профиле.
|
||||||
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
|
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
|
||||||
- [x] Добавить режим карты сети в настройках.
|
- [x] Добавить режим карты сети в настройках.
|
||||||
- [x] Добавить логирование сети и debug-экран маршрутов.
|
- [x] Добавить логирование сети и debug-экран маршрутов.
|
||||||
@@ -123,4 +130,5 @@
|
|||||||
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
||||||
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
||||||
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
|
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
|
||||||
|
- Право на `username` определяется не текущим `peerId`, а подписанным claim владельца. Это защищает имя от простого копирования `peerId` другим устройством.
|
||||||
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "pro.nnnteam.nnnet"
|
applicationId = "pro.nnnteam.nnnet"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.1.4"
|
versionName = "0.1.5"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@android:drawable/sym_def_app_icon"
|
android:icon="@drawable/ic_nnnet_app"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
android:roundIcon="@drawable/ic_nnnet_app"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.NNNet">
|
android:theme="@style/Theme.NNNet">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class ChatActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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.ui.ChatListItem
|
||||||
import pro.nnnteam.nnnet.update.UpdateInfo
|
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||||
|
import pro.nnnteam.nnnet.update.UpdateInstaller
|
||||||
import pro.nnnteam.nnnet.update.UpdateManager
|
import pro.nnnteam.nnnet.update.UpdateManager
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
@@ -216,11 +218,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolvePeerId(value: String): String? {
|
private suspend fun resolvePeerId(value: String): String? {
|
||||||
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
|
val query = value.trim()
|
||||||
return when {
|
val foundPeerId = repository.searchProfiles(query, limit = 1)
|
||||||
':' in value -> value.trim()
|
.firstOrNull()
|
||||||
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
|
?.peerId
|
||||||
}
|
?.takeIf { it.isNotBlank() }
|
||||||
|
return foundPeerId ?: query.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openChat(peerId: String) {
|
private fun openChat(peerId: String) {
|
||||||
@@ -367,8 +370,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.setPositiveButton(R.string.download_update) { _, _ ->
|
.setPositiveButton(R.string.download_update) { _, _ ->
|
||||||
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
lifecycleScope.launch {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
|
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)
|
.setNegativeButton(R.string.later, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class PacketLogActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PacketMapActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package pro.nnnteam.nnnet
|
package pro.nnnteam.nnnet
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ListView
|
||||||
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.button.MaterialButton
|
||||||
@@ -14,11 +18,14 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.data.ProfileSaveResult
|
||||||
import pro.nnnteam.nnnet.data.ProfileEntity
|
import pro.nnnteam.nnnet.data.ProfileEntity
|
||||||
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
import pro.nnnteam.nnnet.mesh.MeshServiceContract
|
||||||
import pro.nnnteam.nnnet.update.UpdateInfo
|
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||||
import pro.nnnteam.nnnet.update.UpdateInstaller
|
import pro.nnnteam.nnnet.update.UpdateInstaller
|
||||||
import pro.nnnteam.nnnet.update.UpdateManager
|
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.BroadcastReceiver
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
@@ -36,6 +43,11 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private lateinit var resultDescriptionText: TextView
|
private lateinit var resultDescriptionText: TextView
|
||||||
private lateinit var resultPeerIdText: TextView
|
private lateinit var resultPeerIdText: TextView
|
||||||
private lateinit var updateProgressText: 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
|
private var receiverRegistered = false
|
||||||
|
|
||||||
@@ -50,7 +62,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
|
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
|
||||||
val query = searchInput.text.toString().trim()
|
val query = searchInput.text.toString().trim()
|
||||||
if (query.isNotEmpty()) {
|
if (query.isNotEmpty()) {
|
||||||
lookupProfile(query)
|
searchProfiles(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +74,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
@@ -81,6 +94,13 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
resultDescriptionText = findViewById(R.id.resultDescriptionText)
|
resultDescriptionText = findViewById(R.id.resultDescriptionText)
|
||||||
resultPeerIdText = findViewById(R.id.resultPeerIdText)
|
resultPeerIdText = findViewById(R.id.resultPeerIdText)
|
||||||
updateProgressText = findViewById(R.id.updateProgressText)
|
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 autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
|
||||||
val versionText = findViewById<TextView>(R.id.versionText)
|
val versionText = findViewById<TextView>(R.id.versionText)
|
||||||
@@ -101,9 +121,15 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
if (query.isEmpty()) {
|
if (query.isEmpty()) {
|
||||||
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} 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 {
|
findViewById<MaterialButton>(R.id.openMapButton).setOnClickListener {
|
||||||
startActivity(Intent(this, PacketMapActivity::class.java))
|
startActivity(Intent(this, PacketMapActivity::class.java))
|
||||||
}
|
}
|
||||||
@@ -164,26 +190,98 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repository.saveLocalProfile(
|
when (val result = repository.saveLocalProfile(
|
||||||
firstName = firstName,
|
firstName = firstName,
|
||||||
lastName = lastName,
|
lastName = lastName,
|
||||||
username = username,
|
username = username,
|
||||||
description = description
|
description = description
|
||||||
)
|
)) {
|
||||||
|
is ProfileSaveResult.Saved -> {
|
||||||
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
|
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 {
|
lifecycleScope.launch {
|
||||||
val profile = repository.profileByUsername(username.removePrefix("@"))
|
val profiles = repository.searchProfiles(query)
|
||||||
renderSearchResult(profile)
|
searchProfileResults.clear()
|
||||||
if (profile == null) {
|
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()
|
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?) {
|
private fun renderSearchResult(profile: ProfileEntity?) {
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
profileResultCard.visibility = android.view.View.GONE
|
profileResultCard.visibility = android.view.View.GONE
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
|
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class MeshDatabase : RoomDatabase() {
|
abstract class MeshDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package pro.nnnteam.nnnet.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import pro.nnnteam.nnnet.mesh.MeshPacket
|
import pro.nnnteam.nnnet.mesh.MeshPacket
|
||||||
import pro.nnnteam.nnnet.mesh.PacketType
|
import pro.nnnteam.nnnet.mesh.PacketType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class MeshRepository(
|
class MeshRepository(
|
||||||
|
private val context: Context,
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val queueDao: OutboundQueueDao,
|
private val queueDao: OutboundQueueDao,
|
||||||
private val profileDao: ProfileDao,
|
private val profileDao: ProfileDao,
|
||||||
@@ -71,7 +73,7 @@ class MeshRepository(
|
|||||||
messageId: String,
|
messageId: String,
|
||||||
attemptCount: Int,
|
attemptCount: Int,
|
||||||
nextAttemptAt: Long,
|
nextAttemptAt: Long,
|
||||||
error: String? = null,
|
@Suppress("UNUSED_PARAMETER") error: String? = null,
|
||||||
now: Long = System.currentTimeMillis()
|
now: Long = System.currentTimeMillis()
|
||||||
) {
|
) {
|
||||||
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
|
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
|
||||||
@@ -106,8 +108,36 @@ class MeshRepository(
|
|||||||
description: String,
|
description: String,
|
||||||
peerId: String = "",
|
peerId: String = "",
|
||||||
now: Long = System.currentTimeMillis()
|
now: Long = System.currentTimeMillis()
|
||||||
): ProfileEntity {
|
): ProfileSaveResult {
|
||||||
val normalizedUsername = normalizeUsername(username)
|
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()
|
profileDao.deleteLocalProfiles()
|
||||||
val entity = ProfileEntity(
|
val entity = ProfileEntity(
|
||||||
username = normalizedUsername,
|
username = normalizedUsername,
|
||||||
@@ -116,69 +146,136 @@ class MeshRepository(
|
|||||||
description = description.trim(),
|
description = description.trim(),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
|
leaseExpiresAt = leaseExpiresAt,
|
||||||
lastSeenAt = now,
|
lastSeenAt = now,
|
||||||
|
publicKey = publicKey,
|
||||||
|
signature = signature,
|
||||||
isLocal = true
|
isLocal = true
|
||||||
)
|
)
|
||||||
profileDao.upsert(entity)
|
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 localProfile(): ProfileEntity? = profileDao.localProfile()
|
||||||
|
|
||||||
suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) {
|
suspend fun refreshLocalClaim(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
|
||||||
if (peerId.isBlank()) return
|
if (peerId.isBlank()) return null
|
||||||
profileDao.updateLocalPeerId(peerId, now, now)
|
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(
|
suspend fun upsertRemoteProfile(
|
||||||
payload: ProfilePayload,
|
payload: ProfilePayload,
|
||||||
peerId: String,
|
peerId: String,
|
||||||
now: Long = System.currentTimeMillis()
|
now: Long = System.currentTimeMillis()
|
||||||
): ProfileEntity? {
|
): RemoteProfileResult {
|
||||||
val normalizedUsername = normalizeUsername(payload.username)
|
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()
|
val localProfile = profileDao.localProfile()
|
||||||
if (localProfile?.username == normalizedUsername) {
|
if (localProfile?.username == normalizedUsername && localProfile.publicKey != payload.publicKey) {
|
||||||
return localProfile
|
return RemoteProfileResult.Rejected(localProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = profileDao.findByUsername(normalizedUsername)
|
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(
|
val entity = ProfileEntity(
|
||||||
username = normalizedUsername,
|
username = normalizedUsername,
|
||||||
firstName = payload.firstName.trim(),
|
firstName = payload.firstName.trim(),
|
||||||
lastName = payload.lastName.trim(),
|
lastName = payload.lastName.trim(),
|
||||||
description = payload.description.trim(),
|
description = payload.description.trim(),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L),
|
updatedAt = payload.updatedAt,
|
||||||
|
leaseExpiresAt = payload.leaseExpiresAt,
|
||||||
lastSeenAt = now,
|
lastSeenAt = now,
|
||||||
|
publicKey = payload.publicKey,
|
||||||
|
signature = payload.signature,
|
||||||
isLocal = false
|
isLocal = false
|
||||||
)
|
)
|
||||||
profileDao.upsert(entity)
|
profileDao.upsert(entity)
|
||||||
return entity
|
return RemoteProfileResult.Stored(entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun profileByUsername(username: String): ProfileEntity? {
|
suspend fun profileByUsername(username: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
|
||||||
return profileDao.findByUsername(normalizeUsername(username))
|
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
|
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()
|
val trimmed = query.trim()
|
||||||
if (trimmed.isBlank()) return emptyList()
|
if (trimmed.isBlank()) return emptyList()
|
||||||
val exact = profileByUsername(trimmed)
|
val lookup = trimmed.removePrefix("@").lowercase()
|
||||||
val fuzzy = profileDao.search("%$trimmed%", limit)
|
val exact = profileByUsername(lookup, now)
|
||||||
return buildList {
|
val fuzzy = profileDao.search("%$trimmed%", limit * 2)
|
||||||
|
.filterNot { it.isExpired(now) }
|
||||||
|
val combined = buildList<ProfileEntity> {
|
||||||
if (exact != null) add(exact)
|
if (exact != null) add(exact)
|
||||||
fuzzy.forEach { candidate ->
|
fuzzy.forEach { candidate ->
|
||||||
if (none { it.username == candidate.username }) {
|
if (this.none { existing -> existing.username == candidate.username && existing.peerId == candidate.peerId }) {
|
||||||
add(candidate)
|
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()
|
suspend fun queuedCount(): Int = queueDao.count()
|
||||||
@@ -222,23 +319,45 @@ class MeshRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun mapNodes(limit: Int = 24): List<ProfileEntity> {
|
suspend fun mapNodes(limit: Int = 24, now: Long = System.currentTimeMillis()): List<ProfileEntity> {
|
||||||
val peerIds = packetTraceDao.recentPeerIds(limit)
|
val peerIds = packetTraceDao.recentPeerIds(limit)
|
||||||
return peerIds.mapNotNull { peerId ->
|
return peerIds.mapNotNull { peerId ->
|
||||||
profileDao.findByPeerId(peerId) ?: ProfileEntity(
|
val existing = profileDao.findByPeerId(peerId)
|
||||||
|
when {
|
||||||
|
existing == null -> ProfileEntity(
|
||||||
username = peerId.lowercase(),
|
username = peerId.lowercase(),
|
||||||
firstName = "",
|
firstName = "",
|
||||||
lastName = "",
|
lastName = "",
|
||||||
description = "",
|
description = "",
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
updatedAt = 0L,
|
updatedAt = 0L,
|
||||||
|
leaseExpiresAt = now + USERNAME_LEASE_MS,
|
||||||
lastSeenAt = 0L,
|
lastSeenAt = 0L,
|
||||||
|
publicKey = "",
|
||||||
|
signature = "",
|
||||||
isLocal = false
|
isLocal = false
|
||||||
)
|
)
|
||||||
|
existing.isExpired(now) -> null
|
||||||
|
else -> existing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeUsername(value: String): String = value.trim().lowercase()
|
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) {
|
private fun directionLabel(direction: String): String = when (direction) {
|
||||||
TRACE_OUTGOING -> "Исходящий"
|
TRACE_OUTGOING -> "Исходящий"
|
||||||
@@ -258,7 +377,22 @@ class MeshRepository(
|
|||||||
const val TRACE_OUTGOING = "outgoing"
|
const val TRACE_OUTGOING = "outgoing"
|
||||||
const val TRACE_INCOMING = "incoming"
|
const val TRACE_INCOMING = "incoming"
|
||||||
const val TRACE_RELAY = "relay"
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,16 +22,43 @@ interface ProfileDao {
|
|||||||
@Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
|
@Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
|
||||||
suspend fun findByPeerId(peerId: String): ProfileEntity?
|
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(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM profiles
|
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
|
ORDER BY isLocal DESC, updatedAt DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun search(query: String, limit: Int): List<ProfileEntity>
|
suspend fun search(query: String, limit: Int): List<ProfileEntity>
|
||||||
|
|
||||||
@Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1")
|
@Query(
|
||||||
suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long)
|
"""
|
||||||
|
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 description: String,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
val updatedAt: Long,
|
val updatedAt: Long,
|
||||||
|
val leaseExpiresAt: Long,
|
||||||
val lastSeenAt: Long,
|
val lastSeenAt: Long,
|
||||||
|
val publicKey: String,
|
||||||
|
val signature: String,
|
||||||
val isLocal: Boolean
|
val isLocal: Boolean
|
||||||
) {
|
) {
|
||||||
fun displayName(): String {
|
fun displayName(): String {
|
||||||
@@ -23,10 +26,15 @@ data class ProfileEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun metaLine(): String {
|
fun metaLine(): String {
|
||||||
return if (peerId.isBlank()) {
|
return buildString {
|
||||||
"@$username"
|
append("@")
|
||||||
} else {
|
append(username)
|
||||||
"@$username · $peerId"
|
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 lastName: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val description: 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()
|
fun normalizedUsername(): String = username.trim().lowercase()
|
||||||
}
|
}
|
||||||
@@ -20,6 +23,9 @@ object ProfilePayloadCodec {
|
|||||||
.put("username", payload.username)
|
.put("username", payload.username)
|
||||||
.put("description", payload.description)
|
.put("description", payload.description)
|
||||||
.put("updatedAt", payload.updatedAt)
|
.put("updatedAt", payload.updatedAt)
|
||||||
|
.put("leaseExpiresAt", payload.leaseExpiresAt)
|
||||||
|
.put("publicKey", payload.publicKey)
|
||||||
|
.put("signature", payload.signature)
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +36,10 @@ object ProfilePayloadCodec {
|
|||||||
lastName = json.optString("lastName", "").trim(),
|
lastName = json.optString("lastName", "").trim(),
|
||||||
username = json.getString("username").trim(),
|
username = json.getString("username").trim(),
|
||||||
description = json.optString("description", "").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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class MeshForegroundService : Service() {
|
|||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val database = MeshDatabase.getInstance(applicationContext)
|
val database = MeshDatabase.getInstance(applicationContext)
|
||||||
repository = MeshRepository(
|
repository = MeshRepository(
|
||||||
|
applicationContext,
|
||||||
database.messageDao(),
|
database.messageDao(),
|
||||||
database.outboundQueueDao(),
|
database.outboundQueueDao(),
|
||||||
database.profileDao(),
|
database.profileDao(),
|
||||||
@@ -68,10 +69,15 @@ class MeshForegroundService : Service() {
|
|||||||
onProfileReceived = { packet ->
|
onProfileReceived = { packet ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
|
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
|
||||||
val stored = repository.upsertRemoteProfile(payload, packet.senderId)
|
when (val result = repository.upsertRemoteProfile(payload, packet.senderId)) {
|
||||||
if (stored != null) {
|
is pro.nnnteam.nnnet.data.RemoteProfileResult.Stored -> {
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${result.profile.username}")
|
||||||
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -131,7 +137,6 @@ class MeshForegroundService : Service() {
|
|||||||
queueProcessor.start()
|
queueProcessor.start()
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
|
||||||
publishLocalProfile(force = true)
|
publishLocalProfile(force = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,14 +147,16 @@ class MeshForegroundService : Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
|
val localProfile = repository.refreshLocalClaim(bleMeshManager.nodeId) ?: return
|
||||||
val localProfile = repository.localProfile() ?: return
|
|
||||||
val payload = ProfilePayload(
|
val payload = ProfilePayload(
|
||||||
firstName = localProfile.firstName,
|
firstName = localProfile.firstName,
|
||||||
lastName = localProfile.lastName,
|
lastName = localProfile.lastName,
|
||||||
username = localProfile.username,
|
username = localProfile.username,
|
||||||
description = localProfile.description,
|
description = localProfile.description,
|
||||||
updatedAt = localProfile.updatedAt
|
updatedAt = localProfile.updatedAt,
|
||||||
|
leaseExpiresAt = localProfile.leaseExpiresAt,
|
||||||
|
publicKey = localProfile.publicKey,
|
||||||
|
signature = localProfile.signature
|
||||||
)
|
)
|
||||||
val sent = bleMeshManager.sendPacket(
|
val sent = bleMeshManager.sendPacket(
|
||||||
MeshPacket(
|
MeshPacket(
|
||||||
|
|||||||
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>
|
||||||
@@ -137,6 +137,16 @@
|
|||||||
android:text="@string/find_profile"
|
android:text="@string/find_profile"
|
||||||
app:cornerRadius="18dp" />
|
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
|
<LinearLayout
|
||||||
android:id="@+id/profileResultCard"
|
android:id="@+id/profileResultCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -241,6 +251,44 @@
|
|||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:textColor="@color/secondary_text"
|
android:textColor="@color/secondary_text"
|
||||||
android:visibility="gone" />
|
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>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<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">Введите username, имя или peerId</string>
|
||||||
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
|
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
|
||||||
<string name="update_check_failed">Не удалось проверить обновления</string>
|
<string name="update_check_failed">Не удалось проверить обновления</string>
|
||||||
<string name="update_download_failed">Не удалось скачать обновление</string>
|
<string name="update_download_failed">Не удалось скачать обновление</string>
|
||||||
@@ -49,14 +49,29 @@
|
|||||||
<string name="profile_description">Описание</string>
|
<string name="profile_description">Описание</string>
|
||||||
<string name="save_profile">Сохранить профиль</string>
|
<string name="save_profile">Сохранить профиль</string>
|
||||||
<string name="search_profile_title">Найти профиль</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="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="username_required">Username обязателен</string>
|
||||||
<string name="profile_saved">Профиль сохранён</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="no_profile_description">Описание не указано</string>
|
||||||
<string name="peer_id_unknown">peerId пока неизвестен</string>
|
<string name="peer_id_unknown">peerId пока неизвестен</string>
|
||||||
<string name="peer_id_value">peerId: %1$s</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="diagnostics_title">Диагностика сети</string>
|
||||||
<string name="map_mode_title">Режим карты</string>
|
<string name="map_mode_title">Режим карты</string>
|
||||||
<string name="packet_log_title">Журнал пакетов</string>
|
<string name="packet_log_title">Журнал пакетов</string>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`.
|
- Diagnostics 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`.
|
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети, разрешение `username <-> peerId`, lease на 14 дней и recovery bundle для переноса идентичности.
|
||||||
|
|
||||||
## Пользовательский сценарий
|
## Пользовательский сценарий
|
||||||
- Главный экран показывает список чатов в стиле Telegram.
|
- Главный экран показывает список чатов в стиле Telegram.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
- Слева в шапке показывается общее количество известных устройств в mesh.
|
- Слева в шапке показывается общее количество известных устройств в mesh.
|
||||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
|
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||||
- Отправка сообщений доступна только из экрана конкретного диалога.
|
- Отправка сообщений доступна только из экрана конкретного диалога.
|
||||||
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
|
- В настройках пользователь редактирует свой профиль, ищет другие профили по `username`, имени, фамилии, полному имени и `peerId`, а также может экспортировать или импортировать recovery bundle.
|
||||||
- В настройках доступны режим карты сети и экран журнала пакетов.
|
- В настройках доступны режим карты сети и экран журнала пакетов.
|
||||||
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
|
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
||||||
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
||||||
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
|
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
|
||||||
|
- Каждый профильный пакет содержит подписанный claim владельца: `username`, имя, описание, `peerId`, `updatedAt`, `leaseExpiresAt`, `publicKey`, `signature`.
|
||||||
|
- Захват чужого `username` блокируется проверкой подписи и активного lease. Если владелец не появляется в сети 14 дней, claim считается протухшим и `username` можно занять заново.
|
||||||
|
- Перенос на новый телефон делается через recovery bundle с ключами владельца: после импорта тот же пользователь может опубликовать прежний `username` уже с новым `peerId`.
|
||||||
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
|
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
|
||||||
|
|
||||||
## Сетевой пакет (черновик)
|
## Сетевой пакет (черновик)
|
||||||
@@ -42,5 +45,5 @@
|
|||||||
|
|
||||||
## Ближайшие шаги
|
## Ближайшие шаги
|
||||||
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
||||||
2. Ввести шифрование payload и подпись пакетов.
|
2. Ввести шифрование payload и подпись уже не только профильных, но и message-пакетов.
|
||||||
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.
|
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,3 +1,4 @@
|
|||||||
- Добавлено меню: Карта сети, Пакеты и Настройки.
|
- Добавлены подписанные username-claims с защитой от захвата чужого имени.
|
||||||
- Добавлен встроенный поток обновления: проверка версии, скачивание APK во временный каталог, остановка mesh и запуск системной установки.
|
- Добавлен recovery bundle для переноса профиля и username на новый телефон.
|
||||||
- Улучшена навигация по диагностическим инструментам сети.
|
- Поиск профилей теперь работает по username, имени, фамилии, полному имени и peerId.
|
||||||
|
- В UI показывается связь peerId и @username через локальный mesh-каталог.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NNNet</title>
|
<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@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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
|
||||||
<link href="assets/css/styles.css" rel="stylesheet" />
|
<link href="assets/css/styles.css" rel="stylesheet" />
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
<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 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user