Add signed username claims and profile recovery
Some checks failed
Android CI / build (push) Has been cancelled

This commit is contained in:
dom4k
2026-03-17 03:15:34 +00:00
parent 0d40a0ae2a
commit 6c715477b4
17 changed files with 567 additions and 75 deletions

View File

@@ -14,8 +14,11 @@
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`, имени, фамилии, полному имени и `peerId`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`, и наоборот по `peerId` в UI показывается связанный `@username`, если claim уже известен.
- Username оформлен как подписанный lease-claim на 14 дней: если владелец не появляется в сети 2 недели, username снова становится свободным.
- Добавлен recovery bundle для переноса профиля и права на username на новый телефон даже без доступа к старому устройству.
- Добавлена защита от захвата чужого `username`: активный claim другого владельца не принимается, а смена `peerId` без подписи владельца не даёт забрать профиль.
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
@@ -88,6 +91,10 @@
- [x] Подключить Room и базовую схему хранения.
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
- [x] Добавить поиск профилей по имени, фамилии, полному имени и `peerId`.
- [x] Добавить lease-механику для `username` с автоматическим освобождением через 14 дней неактивности.
- [x] Добавить перенос профиля на новый телефон через recovery bundle.
- [x] Добавить защиту от захвата чужого `username` и подмены `peerId` в профиле.
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
- [x] Добавить режим карты сети в настройках.
- [x] Добавить логирование сети и debug-экран маршрутов.
@@ -123,4 +130,5 @@
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
- Право на `username` определяется не текущим `peerId`, а подписанным claim владельца. Это защищает имя от простого копирования `peerId` другим устройством.
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.

View File

@@ -86,6 +86,7 @@ class ChatActivity : AppCompatActivity() {
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
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.ChatListItem
import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateInstaller
import pro.nnnteam.nnnet.update.UpdateManager
import java.util.Locale
@@ -95,6 +96,7 @@ class MainActivity : AppCompatActivity() {
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
@@ -216,11 +218,12 @@ class MainActivity : AppCompatActivity() {
}
private suspend fun resolvePeerId(value: String): String? {
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
return when {
':' in value -> value.trim()
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
}
val query = value.trim()
val foundPeerId = repository.searchProfiles(query, limit = 1)
.firstOrNull()
?.peerId
?.takeIf { it.isNotBlank() }
return foundPeerId ?: query.takeIf { it.isNotBlank() }
}
private fun openChat(peerId: String) {
@@ -367,8 +370,16 @@ class MainActivity : AppCompatActivity() {
}
)
.setPositiveButton(R.string.download_update) { _, _ ->
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
lifecycleScope.launch {
val apkFile = withContext(Dispatchers.IO) {
UpdateInstaller.downloadToTempFile(this@MainActivity, updateInfo)
}
if (apkFile == null) {
Toast.makeText(this@MainActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show()
} else {
UpdateInstaller.installDownloadedApk(this@MainActivity, apkFile)
}
}
}
.setNegativeButton(R.string.later, null)
.show()

View File

@@ -23,6 +23,7 @@ class PacketLogActivity : AppCompatActivity() {
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),

View File

@@ -22,6 +22,7 @@ class PacketMapActivity : AppCompatActivity() {
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),

View File

@@ -1,12 +1,16 @@
package pro.nnnteam.nnnet
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
@@ -14,11 +18,14 @@ import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfileSaveResult
import pro.nnnteam.nnnet.data.ProfileEntity
import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.update.UpdateInfo
import pro.nnnteam.nnnet.update.UpdateInstaller
import pro.nnnteam.nnnet.update.UpdateManager
import pro.nnnteam.nnnet.ui.ChatListItem
import pro.nnnteam.nnnet.ui.ChatListAdapter
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
@@ -36,6 +43,11 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var resultDescriptionText: TextView
private lateinit var resultPeerIdText: TextView
private lateinit var updateProgressText: TextView
private lateinit var recoveryInput: EditText
private lateinit var searchResultsView: ListView
private val searchProfileResults = mutableListOf<ProfileEntity>()
private val searchItems = mutableListOf<ChatListItem>()
private lateinit var searchAdapter: ChatListAdapter
private var receiverRegistered = false
@@ -50,7 +62,7 @@ class SettingsActivity : AppCompatActivity() {
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
val query = searchInput.text.toString().trim()
if (query.isNotEmpty()) {
lookupProfile(query)
searchProfiles(query)
}
}
}
@@ -62,6 +74,7 @@ class SettingsActivity : AppCompatActivity() {
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
@@ -81,6 +94,13 @@ class SettingsActivity : AppCompatActivity() {
resultDescriptionText = findViewById(R.id.resultDescriptionText)
resultPeerIdText = findViewById(R.id.resultPeerIdText)
updateProgressText = findViewById(R.id.updateProgressText)
recoveryInput = findViewById(R.id.recoveryInput)
searchResultsView = findViewById(R.id.searchResultsView)
searchAdapter = ChatListAdapter(this, searchItems)
searchResultsView.adapter = searchAdapter
searchResultsView.setOnItemClickListener { _, _, position, _ ->
renderSearchResult(searchProfileResults.getOrNull(position))
}
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(R.id.versionText)
@@ -101,9 +121,15 @@ class SettingsActivity : AppCompatActivity() {
if (query.isEmpty()) {
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
} else {
lookupProfile(query)
searchProfiles(query)
}
}
findViewById<MaterialButton>(R.id.showRecoveryButton).setOnClickListener {
showRecoveryBundle()
}
findViewById<MaterialButton>(R.id.importRecoveryButton).setOnClickListener {
importRecoveryBundle()
}
findViewById<MaterialButton>(R.id.openMapButton).setOnClickListener {
startActivity(Intent(this, PacketMapActivity::class.java))
}
@@ -164,26 +190,98 @@ class SettingsActivity : AppCompatActivity() {
}
lifecycleScope.launch {
repository.saveLocalProfile(
when (val result = repository.saveLocalProfile(
firstName = firstName,
lastName = lastName,
username = username,
description = description
)
)) {
is ProfileSaveResult.Saved -> {
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
usernameInput.setText(result.profile.username)
}
is ProfileSaveResult.UsernameOccupied -> {
Toast.makeText(this@SettingsActivity, R.string.username_taken, Toast.LENGTH_LONG).show()
}
ProfileSaveResult.InvalidUsername -> {
Toast.makeText(this@SettingsActivity, R.string.username_invalid, Toast.LENGTH_LONG).show()
}
}
}
}
private fun lookupProfile(username: String) {
private fun searchProfiles(query: String) {
lifecycleScope.launch {
val profile = repository.profileByUsername(username.removePrefix("@"))
renderSearchResult(profile)
if (profile == null) {
val profiles = repository.searchProfiles(query)
searchProfileResults.clear()
searchProfileResults.addAll(profiles)
searchItems.clear()
searchItems.addAll(
profiles.map { profile ->
ChatListItem(
peerId = profile.peerId,
title = profile.displayName(),
subtitle = buildString {
append("@${profile.username}")
if (profile.description.isNotBlank()) {
append(" · ${profile.description}")
} else if (profile.peerId.isNotBlank()) {
append(" · ${profile.peerId}")
}
},
lastStatus = "",
lastTimestamp = maxOf(profile.updatedAt, profile.lastSeenAt)
)
}
)
searchAdapter.notifyDataSetChanged()
val first = profiles.firstOrNull()
renderSearchResult(first)
if (first == null) {
Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show()
}
}
}
private fun showRecoveryBundle() {
val bundle = repository.exportRecoveryBundle()
recoveryInput.setText(bundle)
AlertDialog.Builder(this)
.setTitle(R.string.recovery_dialog_title)
.setMessage(R.string.recovery_dialog_message)
.setView(EditText(this).apply {
setText(bundle)
setTextIsSelectable(true)
isSingleLine = false
minLines = 6
setPadding(48, 32, 48, 32)
})
.setPositiveButton(R.string.copy_code) { _, _ ->
val clipboard = getSystemService(ClipboardManager::class.java)
clipboard.setPrimaryClip(ClipData.newPlainText("NNNet Recovery", bundle))
Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.ok, null)
.show()
Toast.makeText(this, R.string.recovery_shown, Toast.LENGTH_SHORT).show()
}
private fun importRecoveryBundle() {
val bundle = recoveryInput.text.toString().trim()
if (bundle.isBlank()) {
Toast.makeText(this, R.string.recovery_required, Toast.LENGTH_SHORT).show()
return
}
runCatching {
repository.importRecoveryBundle(bundle)
}.onSuccess {
Toast.makeText(this, R.string.recovery_imported, Toast.LENGTH_SHORT).show()
loadLocalProfile()
}.onFailure {
Toast.makeText(this, R.string.recovery_invalid, Toast.LENGTH_LONG).show()
}
}
private fun renderSearchResult(profile: ProfileEntity?) {
if (profile == null) {
profileResultCard.visibility = android.view.View.GONE

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(
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
version = 3,
version = 4,
exportSchema = false
)
abstract class MeshDatabase : RoomDatabase() {

View File

@@ -1,10 +1,12 @@
package pro.nnnteam.nnnet.data
import android.content.Context
import pro.nnnteam.nnnet.mesh.MeshPacket
import pro.nnnteam.nnnet.mesh.PacketType
import java.util.UUID
class MeshRepository(
private val context: Context,
private val messageDao: MessageDao,
private val queueDao: OutboundQueueDao,
private val profileDao: ProfileDao,
@@ -71,7 +73,7 @@ class MeshRepository(
messageId: String,
attemptCount: Int,
nextAttemptAt: Long,
error: String? = null,
@Suppress("UNUSED_PARAMETER") error: String? = null,
now: Long = System.currentTimeMillis()
) {
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
@@ -106,8 +108,36 @@ class MeshRepository(
description: String,
peerId: String = "",
now: Long = System.currentTimeMillis()
): ProfileEntity {
): ProfileSaveResult {
val normalizedUsername = normalizeUsername(username)
if (!USERNAME_PATTERN.matches(normalizedUsername)) {
return ProfileSaveResult.InvalidUsername
}
val localProfile = profileDao.localProfile()
val keyPair = IdentityManager.getOrCreateKeyPair(context)
val publicKey = IdentityManager.encodePublicKey(keyPair.public)
val existingClaim = profileDao.findByUsername(normalizedUsername)
if (
existingClaim != null &&
existingClaim.publicKey != publicKey &&
!existingClaim.isExpired(now)
) {
return ProfileSaveResult.UsernameOccupied(existingClaim)
}
val leaseExpiresAt = now + USERNAME_LEASE_MS
val canonical = IdentityManager.canonicalProfileClaim(
username = normalizedUsername,
firstName = firstName,
lastName = lastName,
description = description,
peerId = peerId,
updatedAt = now,
leaseExpiresAt = leaseExpiresAt
)
val signature = IdentityManager.signClaim(keyPair.private, canonical)
profileDao.deleteLocalProfiles()
val entity = ProfileEntity(
username = normalizedUsername,
@@ -116,69 +146,136 @@ class MeshRepository(
description = description.trim(),
peerId = peerId,
updatedAt = now,
leaseExpiresAt = leaseExpiresAt,
lastSeenAt = now,
publicKey = publicKey,
signature = signature,
isLocal = true
)
profileDao.upsert(entity)
return entity
if (localProfile != null && localProfile.username != normalizedUsername) {
expireIfOwned(localProfile.username, now)
}
return ProfileSaveResult.Saved(entity)
}
suspend fun localProfile(): ProfileEntity? = profileDao.localProfile()
suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) {
if (peerId.isBlank()) return
profileDao.updateLocalPeerId(peerId, now, now)
suspend fun refreshLocalClaim(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
if (peerId.isBlank()) return null
val localProfile = profileDao.localProfile() ?: return null
val leaseExpiresAt = now + USERNAME_LEASE_MS
val canonical = IdentityManager.canonicalProfileClaim(
username = localProfile.username,
firstName = localProfile.firstName,
lastName = localProfile.lastName,
description = localProfile.description,
peerId = peerId,
updatedAt = now,
leaseExpiresAt = leaseExpiresAt
)
val keyPair = IdentityManager.getOrCreateKeyPair(context)
val signature = IdentityManager.signClaim(keyPair.private, canonical)
profileDao.updateLocalClaim(peerId, now, leaseExpiresAt, now, signature)
return profileDao.localProfile()
}
suspend fun upsertRemoteProfile(
payload: ProfilePayload,
peerId: String,
now: Long = System.currentTimeMillis()
): ProfileEntity? {
): RemoteProfileResult {
val normalizedUsername = normalizeUsername(payload.username)
if (normalizedUsername.isBlank()) return null
if (normalizedUsername.isBlank()) return RemoteProfileResult.Invalid
if (!USERNAME_PATTERN.matches(normalizedUsername)) return RemoteProfileResult.Invalid
if (payload.leaseExpiresAt < now) return RemoteProfileResult.Expired
val canonical = IdentityManager.canonicalProfileClaim(
username = normalizedUsername,
firstName = payload.firstName,
lastName = payload.lastName,
description = payload.description,
peerId = peerId,
updatedAt = payload.updatedAt,
leaseExpiresAt = payload.leaseExpiresAt
)
val publicKey = runCatching { IdentityManager.decodePublicKey(payload.publicKey) }.getOrNull()
?: return RemoteProfileResult.Invalid
val valid = IdentityManager.verifyClaim(publicKey, canonical, payload.signature)
if (!valid) return RemoteProfileResult.Invalid
val localProfile = profileDao.localProfile()
if (localProfile?.username == normalizedUsername) {
return localProfile
if (localProfile?.username == normalizedUsername && localProfile.publicKey != payload.publicKey) {
return RemoteProfileResult.Rejected(localProfile)
}
val existing = profileDao.findByUsername(normalizedUsername)
if (existing != null && existing.publicKey != payload.publicKey && !existing.isExpired(now)) {
return RemoteProfileResult.Rejected(existing)
}
val existingByKey = profileDao.findByPublicKey(payload.publicKey)
if (existingByKey != null && existingByKey.username != normalizedUsername) {
profileDao.upsert(existingByKey.copy(leaseExpiresAt = now - 1))
}
val entity = ProfileEntity(
username = normalizedUsername,
firstName = payload.firstName.trim(),
lastName = payload.lastName.trim(),
description = payload.description.trim(),
peerId = peerId,
updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L),
updatedAt = payload.updatedAt,
leaseExpiresAt = payload.leaseExpiresAt,
lastSeenAt = now,
publicKey = payload.publicKey,
signature = payload.signature,
isLocal = false
)
profileDao.upsert(entity)
return entity
return RemoteProfileResult.Stored(entity)
}
suspend fun profileByUsername(username: String): ProfileEntity? {
return profileDao.findByUsername(normalizeUsername(username))
suspend fun profileByUsername(username: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
val entity = profileDao.findByUsername(normalizeUsername(username)) ?: return null
return if (entity.isExpired(now)) null else entity
}
suspend fun profileByPeerId(peerId: String): ProfileEntity? {
suspend fun profileByPeerId(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? {
if (peerId.isBlank()) return null
return profileDao.findByPeerId(peerId)
val entity = profileDao.findByPeerId(peerId) ?: return null
return if (entity.isExpired(now)) null else entity
}
suspend fun searchProfiles(query: String, limit: Int = 20): List<ProfileEntity> {
suspend fun searchProfiles(query: String, now: Long = System.currentTimeMillis(), limit: Int = 20): List<ProfileEntity> {
val trimmed = query.trim()
if (trimmed.isBlank()) return emptyList()
val exact = profileByUsername(trimmed)
val fuzzy = profileDao.search("%$trimmed%", limit)
return buildList {
val lookup = trimmed.removePrefix("@").lowercase()
val exact = profileByUsername(lookup, now)
val fuzzy = profileDao.search("%$trimmed%", limit * 2)
.filterNot { it.isExpired(now) }
val combined = buildList<ProfileEntity> {
if (exact != null) add(exact)
fuzzy.forEach { candidate ->
if (none { it.username == candidate.username }) {
if (this.none { existing -> existing.username == candidate.username && existing.peerId == candidate.peerId }) {
add(candidate)
}
}
}
return combined.sortedWith(
compareBy<ProfileEntity> {
val fullName = listOf(it.firstName, it.lastName).joinToString(" ").trim().lowercase()
when {
it.username == lookup -> 0
it.peerId.equals(trimmed, ignoreCase = true) -> 1
fullName == trimmed.lowercase() -> 2
it.firstName.equals(trimmed, ignoreCase = true) || it.lastName.equals(trimmed, ignoreCase = true) -> 3
else -> 4
}
}.thenByDescending { it.updatedAt }
).take(limit)
}
suspend fun queuedCount(): Int = queueDao.count()
@@ -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)
return peerIds.mapNotNull { peerId ->
profileDao.findByPeerId(peerId) ?: ProfileEntity(
val existing = profileDao.findByPeerId(peerId)
when {
existing == null -> ProfileEntity(
username = peerId.lowercase(),
firstName = "",
lastName = "",
description = "",
peerId = peerId,
updatedAt = 0L,
leaseExpiresAt = now + USERNAME_LEASE_MS,
lastSeenAt = 0L,
publicKey = "",
signature = "",
isLocal = false
)
existing.isExpired(now) -> null
else -> existing
}
}
}
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) {
TRACE_OUTGOING -> "Исходящий"
@@ -258,7 +377,22 @@ class MeshRepository(
const val TRACE_OUTGOING = "outgoing"
const val TRACE_INCOMING = "incoming"
const val TRACE_RELAY = "relay"
const val USERNAME_LEASE_MS = 14L * 24L * 60L * 60L * 1000L
val USERNAME_PATTERN = Regex("^[a-z0-9_]{4,32}$")
private const val DEFAULT_MAX_ATTEMPTS = 5
}
}
sealed interface ProfileSaveResult {
data class Saved(val profile: ProfileEntity) : ProfileSaveResult
data class UsernameOccupied(val profile: ProfileEntity) : ProfileSaveResult
data object InvalidUsername : ProfileSaveResult
}
sealed interface RemoteProfileResult {
data class Stored(val profile: ProfileEntity) : RemoteProfileResult
data class Rejected(val conflictingProfile: ProfileEntity) : RemoteProfileResult
data object Expired : RemoteProfileResult
data object Invalid : RemoteProfileResult
}

View File

@@ -22,16 +22,43 @@ interface ProfileDao {
@Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
suspend fun findByPeerId(peerId: String): ProfileEntity?
@Query("SELECT * FROM profiles WHERE publicKey = :publicKey ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
suspend fun findByPublicKey(publicKey: String): ProfileEntity?
@Query(
"""
SELECT * FROM profiles
WHERE username LIKE :query OR firstName LIKE :query OR lastName LIKE :query
WHERE username LIKE :query
OR firstName LIKE :query
OR lastName LIKE :query
OR (firstName || ' ' || lastName) LIKE :query
OR (lastName || ' ' || firstName) LIKE :query
OR peerId LIKE :query
ORDER BY isLocal DESC, updatedAt DESC
LIMIT :limit
"""
)
suspend fun search(query: String, limit: Int): List<ProfileEntity>
@Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1")
suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long)
@Query(
"""
UPDATE profiles
SET peerId = :peerId,
updatedAt = :updatedAt,
leaseExpiresAt = :leaseExpiresAt,
lastSeenAt = :lastSeenAt,
signature = :signature
WHERE isLocal = 1
"""
)
suspend fun updateLocalClaim(
peerId: String,
updatedAt: Long,
leaseExpiresAt: Long,
lastSeenAt: Long,
signature: String
)
@Query("SELECT * FROM profiles WHERE isLocal = 0 AND leaseExpiresAt >= :now ORDER BY updatedAt DESC")
suspend fun activeRemoteProfiles(now: Long): List<ProfileEntity>
}

View File

@@ -11,7 +11,10 @@ data class ProfileEntity(
val description: String,
val peerId: String,
val updatedAt: Long,
val leaseExpiresAt: Long,
val lastSeenAt: Long,
val publicKey: String,
val signature: String,
val isLocal: Boolean
) {
fun displayName(): String {
@@ -23,10 +26,15 @@ data class ProfileEntity(
}
fun metaLine(): String {
return if (peerId.isBlank()) {
"@$username"
} else {
"@$username · $peerId"
return buildString {
append("@")
append(username)
if (peerId.isNotBlank()) {
append(" · ")
append(peerId)
}
}
}
fun isExpired(now: Long = System.currentTimeMillis()): Boolean = !isLocal && leaseExpiresAt < now
}

View File

@@ -7,7 +7,10 @@ data class ProfilePayload(
val lastName: String,
val username: String,
val description: String,
val updatedAt: Long
val updatedAt: Long,
val leaseExpiresAt: Long,
val publicKey: String,
val signature: String
) {
fun normalizedUsername(): String = username.trim().lowercase()
}
@@ -20,6 +23,9 @@ object ProfilePayloadCodec {
.put("username", payload.username)
.put("description", payload.description)
.put("updatedAt", payload.updatedAt)
.put("leaseExpiresAt", payload.leaseExpiresAt)
.put("publicKey", payload.publicKey)
.put("signature", payload.signature)
.toString()
}
@@ -30,7 +36,10 @@ object ProfilePayloadCodec {
lastName = json.optString("lastName", "").trim(),
username = json.getString("username").trim(),
description = json.optString("description", "").trim(),
updatedAt = json.optLong("updatedAt", System.currentTimeMillis())
updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
leaseExpiresAt = json.optLong("leaseExpiresAt", 0L),
publicKey = json.getString("publicKey"),
signature = json.getString("signature")
)
}
}

View File

@@ -32,6 +32,7 @@ class MeshForegroundService : Service() {
createNotificationChannel()
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
applicationContext,
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
@@ -68,10 +69,15 @@ class MeshForegroundService : Service() {
onProfileReceived = { packet ->
serviceScope.launch {
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
val stored = repository.upsertRemoteProfile(payload, packet.senderId)
if (stored != null) {
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}")
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.username)
when (val result = repository.upsertRemoteProfile(payload, packet.senderId)) {
is pro.nnnteam.nnnet.data.RemoteProfileResult.Stored -> {
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${result.profile.username}")
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, result.profile.username)
}
is pro.nnnteam.nnnet.data.RemoteProfileResult.Rejected -> {
sendEvent(MeshServiceContract.EVENT_LOG, "Отклонён захват username @${result.conflictingProfile.username}")
}
else -> Unit
}
}
},
@@ -131,7 +137,6 @@ class MeshForegroundService : Service() {
queueProcessor.start()
queueProcessor.poke()
serviceScope.launch {
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
publishLocalProfile(force = true)
}
}
@@ -142,14 +147,16 @@ class MeshForegroundService : Service() {
return
}
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
val localProfile = repository.localProfile() ?: return
val localProfile = repository.refreshLocalClaim(bleMeshManager.nodeId) ?: return
val payload = ProfilePayload(
firstName = localProfile.firstName,
lastName = localProfile.lastName,
username = localProfile.username,
description = localProfile.description,
updatedAt = localProfile.updatedAt
updatedAt = localProfile.updatedAt,
leaseExpiresAt = localProfile.leaseExpiresAt,
publicKey = localProfile.publicKey,
signature = localProfile.signature
)
val sent = bleMeshManager.sendPacket(
MeshPacket(

View File

@@ -137,6 +137,16 @@
android:text="@string/find_profile"
app:cornerRadius="18dp" />
<ListView
android:id="@+id/searchResultsView"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:divider="@android:color/transparent"
android:dividerHeight="8dp"
android:padding="8dp" />
<LinearLayout
android:id="@+id/profileResultCard"
android:layout_width="match_parent"
@@ -241,6 +251,44 @@
android:layout_marginTop="12dp"
android:textColor="@color/secondary_text"
android:visibility="gone" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/recovery_section_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<EditText
android:id="@+id/recoveryInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:gravity="top"
android:hint="@string/recovery_hint"
android:minLines="4"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/showRecoveryButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/show_recovery"
app:cornerRadius="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/importRecoveryButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/import_recovery"
app:cornerRadius="18dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -18,7 +18,7 @@
<string name="permissions_denied">Без разрешений BLE сеть не запустится</string>
<string name="bluetooth_required">Для работы нужен включённый Bluetooth</string>
<string name="bluetooth_unavailable">Bluetooth на устройстве недоступен</string>
<string name="peer_id_required">Введите ID устройства</string>
<string name="peer_id_required">Введите username, имя или peerId</string>
<string name="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string>
<string name="update_download_failed">Не удалось скачать обновление</string>
@@ -49,14 +49,29 @@
<string name="profile_description">Описание</string>
<string name="save_profile">Сохранить профиль</string>
<string name="search_profile_title">Найти профиль</string>
<string name="search_username">Введите username</string>
<string name="search_username">Введите username, имя или peerId</string>
<string name="find_profile">Найти</string>
<string name="enter_username_to_search">Введите username для поиска</string>
<string name="enter_username_to_search">Введите username, имя или peerId для поиска</string>
<string name="username_required">Username обязателен</string>
<string name="profile_saved">Профиль сохранён</string>
<string name="username_taken">Этот username уже занят активным владельцем</string>
<string name="username_invalid">Username должен содержать 4-32 символа: латиница, цифры, _</string>
<string name="no_profile_description">Описание не указано</string>
<string name="peer_id_unknown">peerId пока неизвестен</string>
<string name="peer_id_value">peerId: %1$s</string>
<string name="recovery_section_title">Перенос профиля</string>
<string name="show_recovery">Показать код переноса</string>
<string name="import_recovery">Импортировать код переноса</string>
<string name="recovery_hint">Код переноса профиля</string>
<string name="recovery_shown">Код переноса сформирован</string>
<string name="recovery_required">Вставьте код переноса</string>
<string name="recovery_imported">Код переноса импортирован</string>
<string name="recovery_invalid">Код переноса повреждён или неверен</string>
<string name="recovery_dialog_title">Код переноса</string>
<string name="recovery_dialog_message">Сохраните этот код в надёжном месте. С его помощью можно вернуть username и профиль на новом телефоне.</string>
<string name="ok">ОК</string>
<string name="copy_code">Копировать код</string>
<string name="copied">Скопировано</string>
<string name="diagnostics_title">Диагностика сети</string>
<string name="map_mode_title">Режим карты</string>
<string name="packet_log_title">Журнал пакетов</string>

View File

@@ -8,7 +8,7 @@
- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`.
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`.
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети, разрешение `username <-> peerId`, lease на 14 дней и recovery bundle для переноса идентичности.
## Пользовательский сценарий
- Главный экран показывает список чатов в стиле Telegram.
@@ -16,7 +16,7 @@
- Слева в шапке показывается общее количество известных устройств в mesh.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
- В настройках пользователь редактирует свой профиль, ищет другие профили по `username`, имени, фамилии, полному имени и `peerId`, а также может экспортировать или импортировать recovery bundle.
- В настройках доступны режим карты сети и экран журнала пакетов.
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
@@ -25,6 +25,9 @@
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
- Каждый профильный пакет содержит подписанный claim владельца: `username`, имя, описание, `peerId`, `updatedAt`, `leaseExpiresAt`, `publicKey`, `signature`.
- Захват чужого `username` блокируется проверкой подписи и активного lease. Если владелец не появляется в сети 14 дней, claim считается протухшим и `username` можно занять заново.
- Перенос на новый телефон делается через recovery bundle с ключами владельца: после импорта тот же пользователь может опубликовать прежний `username` уже с новым `peerId`.
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
## Сетевой пакет (черновик)
@@ -42,5 +45,5 @@
## Ближайшие шаги
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
2. Ввести шифрование payload и подпись пакетов.
2. Ввести шифрование payload и подпись уже не только профильных, но и message-пакетов.
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.

View File

@@ -44,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-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-person-badge fs-2"></i><h5 class="mt-2">Профили и поиск</h5><p class="mb-0">Поиск по @username, имени, фамилии и peerId. Связка username ↔ peerId хранится в mesh-каталоге.</p></div></div>
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-key fs-2"></i><h5 class="mt-2">Перенос профиля</h5><p class="mb-0">Recovery bundle позволяет вернуть профиль и username на новом телефоне даже без старого устройства.</p></div></div>
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-shield-check fs-2"></i><h5 class="mt-2">Защита username</h5><p class="mb-0">Профиль подписывается владельцем. Активный username освобождается только после 14 дней неактивности.</p></div></div>
</div>
</div>
</section>