3 Commits

Author SHA1 Message Date
dom4k
9ca8a5cb86 Release v0.1.5
Some checks are pending
Android CI / build (push) Waiting to run
2026-03-17 03:17:37 +00:00
dom4k
6c715477b4 Add signed username claims and profile recovery
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 03:15:34 +00:00
dom4k
0d40a0ae2a Add shared NNNet icon for app and website
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:54:31 +00:00
22 changed files with 607 additions and 82 deletions

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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()

View File

@@ -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(),

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

@@ -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)
username = peerId.lowercase(), when {
firstName = "", existing == null -> ProfileEntity(
lastName = "", username = peerId.lowercase(),
description = "", firstName = "",
peerId = peerId, lastName = "",
updatedAt = 0L, description = "",
lastSeenAt = 0L, peerId = peerId,
isLocal = false updatedAt = 0L,
) leaseExpiresAt = now + USERNAME_LEASE_MS,
lastSeenAt = 0L,
publicKey = "",
signature = "",
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
}

View File

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

View File

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

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View 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

View File

@@ -1,3 +1,4 @@
- Добавлено меню: Карта сети, Пакеты и Настройки. - Добавлены подписанные username-claims с защитой от захвата чужого имени.
- Добавлен встроенный поток обновления: проверка версии, скачивание APK во временный каталог, остановка mesh и запуск системной установки. - Добавлен recovery bundle для переноса профиля и username на новый телефон.
- Улучшена навигация по диагностическим инструментам сети. - Поиск профилей теперь работает по username, имени, фамилии, полному имени и peerId.
- В UI показывается связь peerId и @username через локальный mesh-каталог.

View File

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