9 Commits

Author SHA1 Message Date
dom4k
da681cbd23 Release v0.1.4
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:47:58 +00:00
dom4k
c158fd63b6 Add in-app update installer flow and menu tools
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:43:13 +00:00
dom4k
909d1462f7 Add network map and packet diagnostics
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:33:33 +00:00
dom4k
88388ec115 Release v0.1.3
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-17 02:26:20 +00:00
dom4k
b4df94200e Add distributed user profiles and username directory 2026-03-17 02:25:07 +00:00
dom4k
1cfdb42e04 Refine NNNet UI and rename Android package
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-16 20:29:49 +00:00
dom4k
3f304e901c Require release notes for each version bump
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-16 20:11:04 +00:00
dom4k
9d37001e2b Release v0.1.2
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-16 20:04:32 +00:00
dom4k
f57f4716a5 Automate version tags for each build
Some checks failed
Android CI / build (push) Has been cancelled
2026-03-16 20:01:21 +00:00
67 changed files with 3045 additions and 940 deletions

View File

@@ -9,32 +9,44 @@ APK_PATH := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
PUBLISHED_APK := $(DOWNLOADS_DIR)/app-debug.apk PUBLISHED_APK := $(DOWNLOADS_DIR)/app-debug.apk
VERSION_FILE := $(META_DIR)/version.json VERSION_FILE := $(META_DIR)/version.json
.PHONY: help client-version-bump client-build client-publish publish \ .PHONY: help client-version-bump client-build client-tag client-publish publish require-release-notes \
server-start server-stop server-restart server-status server-rebuild server-start server-stop server-restart server-status server-rebuild
help: help:
@echo "Targets:" @echo "Targets:"
@echo " make client-build - bump version and build debug APK" @echo " make client-build - require release notes, bump version, build debug APK, create git tag"
@echo " make client-tag - create git tag for the current Android version"
@echo " make client-publish - copy APK to website and refresh version metadata" @echo " make client-publish - copy APK to website and refresh version metadata"
@echo " make publish - bump version, build APK, publish client, reload nginx" @echo " make publish - require release notes, bump version, build APK, publish client, reload nginx"
@echo " vars: RELEASE_NOTES='- item 1\n- item 2' or RELEASE_NOTES_FILE=/path/to/file"
@echo " make server-start - start nginx" @echo " make server-start - start nginx"
@echo " make server-stop - stop nginx" @echo " make server-stop - stop nginx"
@echo " make server-restart - restart nginx" @echo " make server-restart - restart nginx"
@echo " make server-status - show nginx status" @echo " make server-status - show nginx status"
@echo " make server-rebuild - test nginx config and reload nginx" @echo " make server-rebuild - test nginx config and reload nginx"
require-release-notes:
@$(PROJECT_ROOT)/scripts/update_release_notes.sh
client-version-bump: client-version-bump:
@$(PROJECT_ROOT)/scripts/bump_version.sh @$(PROJECT_ROOT)/scripts/bump_version.sh
client-build: client-version-bump client-tag:
@$(PROJECT_ROOT)/scripts/create_version_tag.sh
client-build: require-release-notes client-version-bump
cd $(ANDROID_DIR) && ./gradlew assembleDebug --no-daemon cd $(ANDROID_DIR) && ./gradlew assembleDebug --no-daemon
@$(PROJECT_ROOT)/scripts/create_version_tag.sh
client-publish: client-publish:
@mkdir -p $(DOWNLOADS_DIR) $(META_DIR) @mkdir -p $(DOWNLOADS_DIR) $(META_DIR)
cp $(APK_PATH) $(PUBLISHED_APK) cp $(APK_PATH) $(PUBLISHED_APK)
@$(PROJECT_ROOT)/scripts/update_site_metadata.sh @$(PROJECT_ROOT)/scripts/update_site_metadata.sh
publish: client-build client-publish server-rebuild publish:
@$(MAKE) client-build
@$(MAKE) client-publish
@$(MAKE) server-rebuild
server-start: server-start:
sudo systemctl start nginx sudo systemctl start nginx

View File

@@ -11,10 +11,16 @@
## Текущее состояние ## Текущее состояние
- BLE discovery + advertising работают. - BLE discovery + advertising работают.
- Реализован минимальный GATT transport для обмена mesh-пакетами. - Реализован минимальный GATT transport для обмена mesh-пакетами.
- Есть foreground service, Room-хранилище, ACK/retry очередь и базовый Telegram-подобный UI. - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
- Реализованы список чатов, окно диалога, вкладка настроек, ручная проверка обновлений и опциональная автопроверка через `version.json`. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
- Публикация APK и сайта автоматизирована через `Makefile`. - Публикация APK и сайта автоматизирована через `Makefile`.
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
## Стек ## Стек
- Android приложение: **Kotlin** - Android приложение: **Kotlin**
@@ -42,12 +48,12 @@
3. **Messaging Layer** 3. **Messaging Layer**
- личные сообщения; - личные сообщения;
- список чатов и окно диалога; - список чатов и отдельный экран диалога;
- статусы доставки (queued/sent/relayed/delivered). - статусы доставки (queued/sent/relayed/delivered).
4. **Data Layer** 4. **Data Layer**
- локальное хранилище (Room); - локальное хранилище (Room);
- история сообщений и очередь исходящей доставки. - история сообщений, очередь исходящей доставки, каталог профилей и журнал пакетов.
5. **Security Layer** 5. **Security Layer**
- идентификация пользователя; - идентификация пользователя;
@@ -78,10 +84,14 @@
- [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово). - [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово).
- [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово). - [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово).
- [x] Добавить список чатов и базовый UI окна сообщений. - [x] Добавить список чатов и базовый UI окна сообщений.
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
- [x] Подключить Room и базовую схему хранения. - [x] Подключить Room и базовую схему хранения.
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
- [x] Добавить режим карты сети в настройках.
- [x] Добавить логирование сети и debug-экран маршрутов. - [x] Добавить логирование сети и debug-экран маршрутов.
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента. - [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
- [ ] Реализовать базовую регистрацию пользователя (локальный профиль).
- [ ] Добавить шифрование полезной нагрузки сообщений. - [ ] Добавить шифрование полезной нагрузки сообщений.
- [ ] Написать инструментальные тесты BLE-обмена. - [ ] Написать инструментальные тесты BLE-обмена.
- [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap. - [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap.
@@ -95,13 +105,22 @@
- `docs/` — документация протокола и архитектуры - `docs/` — документация протокола и архитектуры
## Автоматизация ## Автоматизация
- `make client-build` увеличить версию и собрать `debug` APK. - `make client-build RELEASE_NOTES='- пункт 1\n- пункт 2'` — обновить `release-notes.txt`, увеличить версию, собрать `debug` APK и создать git-тег `vX.Y.Z`.
- `make client-tag` — создать git-тег для текущей версии вручную.
- `make client-publish` — опубликовать собранный APK на сайт и обновить `version.json`. - `make client-publish` — опубликовать собранный APK на сайт и обновить `version.json`.
- `make publish` увеличить версию, собрать APK, опубликовать клиент и перезагрузить `nginx`. - `make publish RELEASE_NOTES='- пункт 1\n- пункт 2'` — обновить release notes, увеличить версию, собрать APK, опубликовать клиент и перезагрузить `nginx`.
- Вместо `RELEASE_NOTES` можно передать `RELEASE_NOTES_FILE=/path/to/file`.
- `make server-start|server-stop|server-restart|server-status|server-rebuild` — управление `nginx`. - `make server-start|server-stop|server-restart|server-status|server-rebuild` — управление `nginx`.
## Лицензия ## Лицензия
Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE). Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE).
## Ближайший следующий шаг ## Ближайший следующий шаг
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами. Добавить шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
## Ограничения сети
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.

View File

@@ -5,15 +5,15 @@ plugins {
} }
android { android {
namespace = "com.schoolmesh.messenger" namespace = "pro.nnnteam.nnnet"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.schoolmesh.messenger" applicationId = "pro.nnnteam.nnnet"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 2 versionCode = 5
versionName = "0.1.1" versionName = "0.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -24,7 +25,20 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SchoolMeshMessenger"> android:theme="@style/Theme.NNNet">
<activity
android:name=".PacketLogActivity"
android:exported="false" />
<activity
android:name=".PacketMapActivity"
android:exported="false" />
<activity
android:name=".SettingsActivity"
android:exported="false" />
<activity
android:name=".ChatActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
@@ -40,6 +54,16 @@
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="connectedDevice" /> android:foregroundServiceType="connectedDevice" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -1,487 +0,0 @@
package com.schoolmesh.messenger
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.switchmaterial.SwitchMaterial
import com.schoolmesh.messenger.data.ChatSummary
import com.schoolmesh.messenger.data.MeshDatabase
import com.schoolmesh.messenger.data.MeshRepository
import com.schoolmesh.messenger.mesh.MeshForegroundService
import com.schoolmesh.messenger.mesh.MeshServiceContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var statusText: TextView
private lateinit var peersText: TextView
private lateinit var logsText: TextView
private lateinit var activeChatTitle: TextView
private lateinit var targetInput: EditText
private lateinit var messageInput: EditText
private lateinit var chatListView: ListView
private lateinit var messageListView: ListView
private lateinit var chatsScreen: android.view.View
private lateinit var settingsScreen: android.view.View
private lateinit var autoUpdateSwitch: SwitchMaterial
private val peers = linkedSetOf<String>()
private val logs = ArrayDeque<String>()
private val chatSummaries = mutableListOf<ChatSummary>()
private val chatItems = mutableListOf<String>()
private val messageItems = mutableListOf<String>()
private lateinit var chatAdapter: ArrayAdapter<String>
private lateinit var messageAdapter: ArrayAdapter<String>
private var activePeerId: String? = null
private var pendingSend: PendingSend? = null
private var pendingStartRequested = false
private val prefs by lazy {
getSharedPreferences("nnnet_settings", Context.MODE_PRIVATE)
}
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return
when (eventType) {
MeshServiceContract.EVENT_STATUS -> updateStatus(value)
MeshServiceContract.EVENT_PEER -> addPeer(value)
MeshServiceContract.EVENT_LOG -> appendLog(value)
MeshServiceContract.EVENT_MESSAGES_CHANGED -> {
refreshChats()
refreshMessages()
}
}
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val allGranted = result.values.all { it }
if (allGranted) {
ensureBluetoothEnabledAndContinue()
} else {
updateStatus("Нет BLE-разрешений")
appendLog("Permissions denied by user")
Toast.makeText(this, "Permissions denied", Toast.LENGTH_SHORT).show()
}
}
private val enableBluetoothLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (bluetoothAdapter()?.isEnabled == true) {
continueAfterBluetoothReady()
} else {
updateStatus("Bluetooth is disabled")
appendLog("Bluetooth enable request denied")
Toast.makeText(this, "Bluetooth is required", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
statusText = findViewById(R.id.statusText)
peersText = findViewById(R.id.peersText)
logsText = findViewById(R.id.logsText)
activeChatTitle = findViewById(R.id.activeChatTitle)
targetInput = findViewById(R.id.targetInput)
messageInput = findViewById(R.id.messageInput)
chatListView = findViewById(R.id.chatListView)
messageListView = findViewById(R.id.messageListView)
chatsScreen = findViewById(R.id.chatsScreen)
settingsScreen = findViewById(R.id.settingsScreen)
autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch)
chatAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, chatItems)
messageAdapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, messageItems)
chatListView.adapter = chatAdapter
messageListView.adapter = messageAdapter
findViewById<Button>(R.id.btnTabChats).setOnClickListener { showChats() }
findViewById<Button>(R.id.btnTabSettings).setOnClickListener { showSettings() }
findViewById<Button>(R.id.btnStartMesh).setOnClickListener {
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
findViewById<Button>(R.id.btnStopMesh).setOnClickListener {
MeshForegroundService.stop(this)
updateStatus("Mesh stopped")
appendLog("Mesh service stop requested")
}
findViewById<Button>(R.id.btnSendMessage).setOnClickListener {
enqueueMessageFromUi()
}
findViewById<Button>(R.id.btnCheckUpdates).setOnClickListener {
checkForUpdates(manual = true)
}
chatListView.setOnItemClickListener { _, _, position, _ ->
val chat = chatSummaries[position]
activePeerId = chat.peerId
targetInput.setText(chat.peerId)
activeChatTitle.text = chat.peerId
refreshMessages()
}
autoUpdateSwitch.isChecked = prefs.getBoolean(KEY_AUTO_UPDATE, false)
autoUpdateSwitch.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(KEY_AUTO_UPDATE, isChecked).apply()
appendLog("Auto update set to $isChecked")
}
renderPeers()
renderLogs()
refreshChats()
refreshMessages()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
refreshChats()
refreshMessages()
if (autoUpdateSwitch.isChecked) {
checkForUpdates(manual = false)
}
}
override fun onStop() {
unregisterReceiver(meshEventReceiver)
super.onStop()
}
private fun showChats() {
chatsScreen.visibility = android.view.View.VISIBLE
settingsScreen.visibility = android.view.View.GONE
}
private fun showSettings() {
chatsScreen.visibility = android.view.View.GONE
settingsScreen.visibility = android.view.View.VISIBLE
}
private fun registerMeshReceiver() {
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(meshEventReceiver, filter)
}
}
private fun enqueueMessageFromUi() {
val targetId = targetInput.text.toString().trim()
val body = messageInput.text.toString().trim()
if (targetId.isEmpty() || body.isEmpty()) {
Toast.makeText(this, "Target and message are required", Toast.LENGTH_SHORT).show()
return
}
pendingSend = PendingSend(targetId, body)
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
appendLog("Message queued for $targetId")
messageInput.text?.clear()
}
private fun ensurePermissionsAndMaybeStart() {
val missing = requiredPermissions().filter { permission ->
ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
ensureBluetoothEnabledAndContinue()
} else {
permissionLauncher.launch(missing.toTypedArray())
}
}
private fun ensureBluetoothEnabledAndContinue() {
val adapter = bluetoothAdapter()
if (adapter == null) {
updateStatus("Bluetooth adapter unavailable")
appendLog("Bluetooth adapter unavailable")
return
}
if (adapter.isEnabled) {
continueAfterBluetoothReady()
} else {
val enableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
enableBluetoothLauncher.launch(enableIntent)
}
}
private fun continueAfterBluetoothReady() {
if (pendingStartRequested) {
startMesh()
pendingStartRequested = false
}
pendingSend?.let {
activePeerId = it.targetId
targetInput.setText(it.targetId)
activeChatTitle.text = it.targetId
MeshForegroundService.sendMessage(this, it.targetId, it.body)
pendingSend = null
}
refreshChats()
refreshMessages()
}
private fun requiredPermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions += Manifest.permission.BLUETOOTH_SCAN
permissions += Manifest.permission.BLUETOOTH_CONNECT
permissions += Manifest.permission.BLUETOOTH_ADVERTISE
} else {
permissions += Manifest.permission.ACCESS_FINE_LOCATION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions += Manifest.permission.POST_NOTIFICATIONS
}
return permissions
}
private fun startMesh() {
MeshForegroundService.start(this)
updateStatus("Foreground service starting")
appendLog("Mesh service start requested")
}
private fun bluetoothAdapter(): BluetoothAdapter? {
val manager = getSystemService(BluetoothManager::class.java)
return manager?.adapter
}
private fun refreshChats() {
lifecycleScope.launch {
val chats = repository.chatSummaries()
chatSummaries.clear()
chatSummaries.addAll(chats)
chatItems.clear()
chatItems.addAll(
chats.map { chat ->
val time = timestampFormatter.format(Date(chat.lastTimestamp))
"${chat.peerId}\n${chat.lastBody}\n$time · ${chat.lastStatus}"
}
)
chatAdapter.notifyDataSetChanged()
if (activePeerId == null && chats.isNotEmpty()) {
activePeerId = chats.first().peerId
targetInput.setText(activePeerId)
activeChatTitle.text = activePeerId
}
}
}
private fun refreshMessages() {
val peerId = activePeerId ?: targetInput.text.toString().trim()
if (peerId.isEmpty()) {
messageItems.clear()
messageItems.add("Select a chat or enter a peer id")
messageAdapter.notifyDataSetChanged()
return
}
lifecycleScope.launch {
val messages = repository.messagesForPeer(peerId)
messageItems.clear()
if (messages.isEmpty()) {
messageItems.add("No messages with $peerId yet")
} else {
messageItems.addAll(
messages
.asReversed()
.map { message ->
val time = timestampFormatter.format(Date(message.createdAt))
val bubble = if (message.direction == MeshRepository.DIRECTION_OUTGOING) "You" else message.senderId
"[$time] $bubble\n${message.body}\n${message.status}"
}
)
}
messageAdapter.notifyDataSetChanged()
}
}
private fun checkForUpdates(manual: Boolean) {
lifecycleScope.launch {
val updateInfo = withContext(Dispatchers.IO) {
runCatching {
val connection = URL(UPDATE_METADATA_URL).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { reader ->
val json = JSONObject(reader.readText())
UpdateInfo(
versionCode = json.getInt("versionCode"),
versionName = json.getString("versionName"),
apkPath = json.getString("apkPath"),
releaseNotesTitle = json.optString("releaseNotesTitle", "Что нового"),
releaseNotesPath = json.optString("releaseNotesPath", "")
)
}
}.getOrNull()
}
if (updateInfo == null) {
if (manual) {
Toast.makeText(this@MainActivity, "Failed to check updates", Toast.LENGTH_SHORT).show()
}
appendLog("Update check failed")
return@launch
}
if (updateInfo.versionCode > currentVersionCode()) {
appendLog("Update found: ${updateInfo.versionName}")
showUpdateDialog(updateInfo)
} else if (manual) {
Toast.makeText(this@MainActivity, "You already have the latest version", Toast.LENGTH_SHORT).show()
}
}
}
private fun showUpdateDialog(updateInfo: UpdateInfo) {
lifecycleScope.launch {
val releaseNotes = withContext(Dispatchers.IO) {
fetchReleaseNotes(updateInfo.releaseNotesPath)
}
AlertDialog.Builder(this@MainActivity)
.setTitle(updateInfo.releaseNotesTitle)
.setMessage(
buildString {
append("Version ${updateInfo.versionName} is available.")
if (!releaseNotes.isNullOrBlank()) {
append("\n\n")
append(releaseNotes.trim())
}
}
)
.setPositiveButton("Open download") { _, _ ->
val url = if (updateInfo.apkPath.startsWith("http")) {
updateInfo.apkPath
} else {
"$UPDATE_BASE_URL/${updateInfo.apkPath.trimStart('/')}"
}
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
.setNegativeButton("Later", null)
.show()
}
}
private fun fetchReleaseNotes(path: String): String? {
if (path.isBlank()) return null
val url = if (path.startsWith("http")) path else "$UPDATE_BASE_URL/${path.trimStart('/')}"
return runCatching {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { it.readText() }
}.getOrNull()
}
private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
}
}
private fun updateStatus(text: String) {
statusText.text = text
}
private fun addPeer(address: String) {
if (peers.add(address)) {
renderPeers()
}
}
private fun appendLog(message: String) {
if (logs.size >= MAX_LOG_ENTRIES) {
logs.removeFirst()
}
logs.addLast(message)
renderLogs()
}
private fun renderPeers() {
peersText.text = if (peers.isEmpty()) {
"Nearby peers will appear here"
} else {
"Peers online: ${peers.joinToString(separator = ", ")}"
}
}
private fun renderLogs() {
logsText.text = if (logs.isEmpty()) {
"Log is empty"
} else {
logs.joinToString(separator = "\n")
}
}
companion object {
private const val KEY_AUTO_UPDATE = "auto_update"
private const val MAX_LOG_ENTRIES = 30
private const val UPDATE_BASE_URL = "https://net.nnn-team.pro"
private const val UPDATE_METADATA_URL = "$UPDATE_BASE_URL/assets/meta/version.json"
private val timestampFormatter = SimpleDateFormat("HH:mm", Locale.US)
}
private data class PendingSend(
val targetId: String,
val body: String
)
private data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val apkPath: String,
val releaseNotesTitle: String,
val releaseNotesPath: String
)
}

View File

@@ -1,113 +0,0 @@
package com.schoolmesh.messenger.data
import com.schoolmesh.messenger.mesh.MeshPacket
import com.schoolmesh.messenger.mesh.PacketType
import java.util.UUID
class MeshRepository(
private val messageDao: MessageDao,
private val queueDao: OutboundQueueDao
) {
suspend fun enqueueOutgoingMessage(
senderId: String,
targetId: String,
body: String,
now: Long = System.currentTimeMillis()
): String {
val messageId = UUID.randomUUID().toString()
messageDao.upsert(
MessageEntity(
messageId = messageId,
senderId = senderId,
targetId = targetId,
body = body,
packetType = PacketType.MESSAGE.name,
direction = DIRECTION_OUTGOING,
status = STATUS_QUEUED,
createdAt = now,
updatedAt = now
)
)
queueDao.upsert(
OutboundQueueEntity(
messageId = messageId,
targetId = targetId,
body = body,
nextAttemptAt = now,
attemptCount = 0,
maxAttempts = DEFAULT_MAX_ATTEMPTS,
createdAt = now
)
)
return messageId
}
suspend fun recordIncomingMessage(packet: MeshPacket, now: Long = System.currentTimeMillis()) {
if (messageDao.findById(packet.messageId) != null) return
messageDao.upsert(
MessageEntity(
messageId = packet.messageId,
senderId = packet.senderId,
targetId = packet.targetId,
body = packet.payload,
packetType = packet.type.name,
direction = DIRECTION_INCOMING,
status = STATUS_DELIVERED,
createdAt = packet.timestamp,
updatedAt = now,
ackedAt = now
)
)
}
suspend fun markAckDelivered(originalMessageId: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(originalMessageId, STATUS_DELIVERED, now, now)
queueDao.delete(originalMessageId)
}
suspend fun markSendAttempt(
messageId: String,
attemptCount: Int,
nextAttemptAt: Long,
error: String? = null,
now: Long = System.currentTimeMillis()
) {
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
queueDao.updateAttempt(messageId, nextAttemptAt, attemptCount, error)
}
suspend fun markFailed(messageId: String, error: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(messageId, STATUS_FAILED, now, null)
queueDao.delete(messageId)
}
suspend fun readyQueue(now: Long = System.currentTimeMillis(), limit: Int = 20): List<OutboundQueueEntity> {
return queueDao.readyToSend(now, limit)
}
suspend fun recentMessages(limit: Int = 50): List<MessageEntity> {
return messageDao.recentMessages(limit)
}
suspend fun chatSummaries(): List<ChatSummary> {
return messageDao.chatSummaries()
}
suspend fun messagesForPeer(peerId: String, limit: Int = 100): List<MessageEntity> {
return messageDao.messagesForPeer(peerId, limit)
}
suspend fun queuedCount(): Int = queueDao.count()
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_SENT = "sent"
const val STATUS_DELIVERED = "delivered"
const val STATUS_FAILED = "failed"
const val DIRECTION_INCOMING = "incoming"
const val DIRECTION_OUTGOING = "outgoing"
private const val DEFAULT_MAX_ATTEMPTS = 5
}
}

View File

@@ -1,7 +0,0 @@
package com.schoolmesh.messenger.mesh
enum class PacketType {
MESSAGE,
ACK,
PRESENCE
}

View File

@@ -0,0 +1,225 @@
package pro.nnnteam.nnnet
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.MessageEntity
import pro.nnnteam.nnnet.mesh.MeshForegroundService
import pro.nnnteam.nnnet.mesh.MeshServiceContract
import pro.nnnteam.nnnet.ui.MessageListAdapter
class ChatActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var titleText: TextView
private lateinit var subtitleText: TextView
private lateinit var messageInput: EditText
private lateinit var emptyStateText: TextView
private lateinit var messagesListView: ListView
private val messages = mutableListOf<MessageEntity>()
private lateinit var adapter: MessageListAdapter
private var receiverRegistered = false
private var pendingStartRequested = false
private var pendingBody: String? = null
private lateinit var peerId: String
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
when (eventType) {
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages()
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshHeader()
}
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
if (result.values.all { it }) {
ensureBluetoothEnabledAndContinue()
} else {
Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show()
}
}
private val enableBluetoothLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (bluetoothAdapter()?.isEnabled == true) {
continueAfterBluetoothReady()
} else {
Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_chat)
peerId = intent.getStringExtra(EXTRA_PEER_ID)?.trim().orEmpty()
if (peerId.isEmpty()) {
finish()
return
}
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
titleText = findViewById(R.id.chatTitleText)
subtitleText = findViewById(R.id.chatSubtitleText)
messageInput = findViewById(R.id.messageInput)
emptyStateText = findViewById(R.id.emptyStateText)
messagesListView = findViewById(R.id.messageListView)
adapter = MessageListAdapter(this, messages)
messagesListView.adapter = adapter
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() }
refreshHeader()
refreshMessages()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
refreshHeader()
refreshMessages()
}
override fun onStop() {
if (receiverRegistered) {
unregisterReceiver(meshEventReceiver)
receiverRegistered = false
}
super.onStop()
}
private fun registerMeshReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(meshEventReceiver, filter)
}
receiverRegistered = true
}
private fun refreshHeader() {
lifecycleScope.launch {
val profile = repository.profileByPeerId(peerId)
titleText.text = profile?.displayName() ?: peerId
subtitleText.text = profile?.metaLine() ?: peerId
}
}
private fun sendMessage() {
val body = messageInput.text.toString().trim()
if (body.isEmpty()) {
Toast.makeText(this, R.string.message_required, Toast.LENGTH_SHORT).show()
return
}
pendingBody = body
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
private fun ensurePermissionsAndMaybeStart() {
val missing = requiredPermissions().filter { permission ->
ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
ensureBluetoothEnabledAndContinue()
} else {
permissionLauncher.launch(missing.toTypedArray())
}
}
private fun ensureBluetoothEnabledAndContinue() {
val adapter = bluetoothAdapter()
if (adapter == null) {
Toast.makeText(this, R.string.bluetooth_unavailable, Toast.LENGTH_SHORT).show()
return
}
if (adapter.isEnabled) {
continueAfterBluetoothReady()
} else {
enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
}
private fun continueAfterBluetoothReady() {
if (pendingStartRequested) {
MeshForegroundService.start(this)
pendingStartRequested = false
}
val body = pendingBody ?: return
MeshForegroundService.sendMessage(this, peerId, body)
messageInput.text?.clear()
pendingBody = null
}
private fun refreshMessages() {
lifecycleScope.launch {
val loadedMessages = repository.messagesForPeer(peerId).asReversed()
messages.clear()
messages.addAll(loadedMessages)
adapter.notifyDataSetChanged()
emptyStateText.visibility = if (loadedMessages.isEmpty()) View.VISIBLE else View.GONE
}
}
private fun bluetoothAdapter(): BluetoothAdapter? {
val manager = getSystemService(BluetoothManager::class.java)
return manager?.adapter
}
private fun requiredPermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions += Manifest.permission.BLUETOOTH_SCAN
permissions += Manifest.permission.BLUETOOTH_CONNECT
permissions += Manifest.permission.BLUETOOTH_ADVERTISE
} else {
permissions += Manifest.permission.ACCESS_FINE_LOCATION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions += Manifest.permission.POST_NOTIFICATIONS
}
return permissions
}
companion object {
const val EXTRA_PEER_ID = "peer_id"
}
}

View File

@@ -0,0 +1,407 @@
package pro.nnnteam.nnnet
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.mesh.MeshForegroundService
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.UpdateManager
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var deviceCountText: TextView
private lateinit var statusBadge: View
private lateinit var statusBadgeText: TextView
private lateinit var emptyStateText: TextView
private lateinit var chatListView: ListView
private val peers = linkedSetOf<String>()
private val chatItems = mutableListOf<ChatListItem>()
private lateinit var chatAdapter: ChatListAdapter
private var receiverRegistered = false
private var pendingStartRequested = false
private var meshEnabled = false
private val prefs by lazy {
getSharedPreferences(UpdateManager.PREFS_NAME, Context.MODE_PRIVATE)
}
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return
when (eventType) {
MeshServiceContract.EVENT_STATUS -> updateMeshStatus(value)
MeshServiceContract.EVENT_PEER -> addPeer(value)
MeshServiceContract.EVENT_MESSAGES_CHANGED,
MeshServiceContract.EVENT_PROFILES_CHANGED -> refreshChats()
}
}
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
if (result.values.all { it }) {
ensureBluetoothEnabledAndContinue()
} else {
Toast.makeText(this, R.string.permissions_denied, Toast.LENGTH_SHORT).show()
}
}
private val enableBluetoothLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (bluetoothAdapter()?.isEnabled == true) {
continueAfterBluetoothReady()
} else {
Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
deviceCountText = findViewById(R.id.deviceCountText)
statusBadge = findViewById(R.id.statusBadge)
statusBadgeText = findViewById(R.id.statusBadgeText)
emptyStateText = findViewById(R.id.emptyStateText)
chatListView = findViewById(R.id.chatListView)
chatAdapter = ChatListAdapter(this, chatItems)
chatListView.adapter = chatAdapter
chatListView.setOnItemClickListener { _, _, position, _ ->
openChat(chatItems[position].peerId)
}
statusBadge.setOnClickListener { toggleMesh() }
findViewById<ImageButton>(R.id.menuButton).setOnClickListener { showMenu(it) }
findViewById<FloatingActionButton>(R.id.newChatButton).setOnClickListener { showNewChatDialog() }
renderDeviceCount()
renderStatusBadge()
refreshChats()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
refreshChats()
if (prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)) {
checkForUpdates(manual = false)
}
}
override fun onStop() {
if (receiverRegistered) {
unregisterReceiver(meshEventReceiver)
receiverRegistered = false
}
super.onStop()
}
private fun registerMeshReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(meshEventReceiver, filter)
}
receiverRegistered = true
}
private fun showMenu(anchor: View) {
PopupMenu(this, anchor).apply {
menuInflater.inflate(R.menu.main_menu, menu)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.menu_map -> {
startActivity(Intent(this@MainActivity, PacketMapActivity::class.java))
true
}
R.id.menu_packets -> {
startActivity(Intent(this@MainActivity, PacketLogActivity::class.java))
true
}
R.id.menu_settings -> {
startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
true
}
else -> false
}
}
show()
}
}
private fun showNewChatDialog() {
val input = EditText(this).apply {
hint = getString(R.string.hint_chat_target)
setSingleLine()
setPadding(48, 32, 48, 32)
}
AlertDialog.Builder(this)
.setTitle(R.string.new_chat_title)
.setView(input)
.setPositiveButton(R.string.open_chat, null)
.setNegativeButton(R.string.cancel, null)
.create()
.also { dialog ->
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val value = input.text.toString().trim()
if (value.isEmpty()) {
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
lifecycleScope.launch {
val resolvedPeerId = resolvePeerId(value)
if (resolvedPeerId != null) {
dialog.dismiss()
openChat(resolvedPeerId)
} else {
Toast.makeText(
this@MainActivity,
R.string.profile_not_found_locally,
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
.show()
}
private suspend fun resolvePeerId(value: String): String? {
val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault())
return when {
':' in value -> value.trim()
else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() }
}
}
private fun openChat(peerId: String) {
startActivity(Intent(this, ChatActivity::class.java).putExtra(ChatActivity.EXTRA_PEER_ID, peerId))
}
private fun toggleMesh() {
if (meshEnabled) {
MeshForegroundService.stop(this)
meshEnabled = false
peers.clear()
renderStatusBadge()
renderDeviceCount()
} else {
pendingStartRequested = true
ensurePermissionsAndMaybeStart()
}
}
private fun ensurePermissionsAndMaybeStart() {
val missing = requiredPermissions().filter { permission ->
ContextCompat.checkSelfPermission(this, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
ensureBluetoothEnabledAndContinue()
} else {
permissionLauncher.launch(missing.toTypedArray())
}
}
private fun ensureBluetoothEnabledAndContinue() {
val adapter = bluetoothAdapter()
if (adapter == null) {
Toast.makeText(this, R.string.bluetooth_unavailable, Toast.LENGTH_SHORT).show()
return
}
if (adapter.isEnabled) {
continueAfterBluetoothReady()
} else {
enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
}
private fun continueAfterBluetoothReady() {
if (pendingStartRequested) {
MeshForegroundService.start(this)
meshEnabled = true
pendingStartRequested = false
renderStatusBadge()
renderDeviceCount()
}
}
private fun refreshChats() {
lifecycleScope.launch {
val chats = repository.chatSummaries()
val mappedItems = chats.map { chat ->
val profile = repository.profileByPeerId(chat.peerId)
val title = profile?.displayName() ?: chat.peerId
val subtitlePrefix = profile?.let { "@${it.username} · " }.orEmpty()
ChatListItem(
peerId = chat.peerId,
title = title,
subtitle = subtitlePrefix + chat.lastBody,
lastStatus = chat.lastStatus,
lastTimestamp = chat.lastTimestamp
)
}
chatItems.clear()
chatItems.addAll(mappedItems)
chatAdapter.notifyDataSetChanged()
emptyStateText.visibility = if (mappedItems.isEmpty()) View.VISIBLE else View.GONE
}
}
private fun addPeer(peerId: String) {
if (peers.add(peerId)) {
renderDeviceCount()
}
}
private fun updateMeshStatus(status: String) {
val normalized = status.lowercase(Locale.getDefault())
if (normalized.contains("останов") || normalized.contains("оффлайн")) {
meshEnabled = false
peers.clear()
} else if (
normalized.contains("актив") ||
normalized.contains("запуска") ||
normalized.contains("в сети") ||
normalized.contains("устройство") ||
normalized.contains("сообщение")
) {
meshEnabled = true
}
renderStatusBadge()
renderDeviceCount()
}
private fun renderStatusBadge() {
statusBadgeText.text = getString(if (meshEnabled) R.string.status_online else R.string.status_offline)
statusBadge.setBackgroundResource(
if (meshEnabled) R.drawable.bg_status_online else R.drawable.bg_status_offline
)
}
private fun renderDeviceCount() {
val totalDevices = if (meshEnabled) peers.size + 1 else 1
deviceCountText.text = getString(R.string.total_devices, totalDevices)
}
private fun checkForUpdates(manual: Boolean) {
lifecycleScope.launch {
val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
if (updateInfo == null) {
if (manual) {
Toast.makeText(this@MainActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
}
return@launch
}
if (updateInfo.versionCode > currentVersionCode()) {
showUpdateDialog(updateInfo)
} else if (manual) {
Toast.makeText(this@MainActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show()
}
}
}
private fun showUpdateDialog(updateInfo: UpdateInfo) {
lifecycleScope.launch {
val releaseNotes = withContext(Dispatchers.IO) {
UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath)
}
AlertDialog.Builder(this@MainActivity)
.setTitle(updateInfo.releaseNotesTitle)
.setMessage(
buildString {
append(getString(R.string.update_available_message, updateInfo.versionName))
if (!releaseNotes.isNullOrBlank()) {
append("\n\n")
append(releaseNotes.trim())
}
}
)
.setPositiveButton(R.string.download_update) { _, _ ->
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
}
.setNegativeButton(R.string.later, null)
.show()
}
}
private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
}
}
private fun bluetoothAdapter(): BluetoothAdapter? {
val manager = getSystemService(BluetoothManager::class.java)
return manager?.adapter
}
private fun requiredPermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions += Manifest.permission.BLUETOOTH_SCAN
permissions += Manifest.permission.BLUETOOTH_CONNECT
permissions += Manifest.permission.BLUETOOTH_ADVERTISE
} else {
permissions += Manifest.permission.ACCESS_FINE_LOCATION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions += Manifest.permission.POST_NOTIFICATIONS
}
return permissions
}
}

View File

@@ -0,0 +1,60 @@
package pro.nnnteam.nnnet
import android.os.Bundle
import android.widget.ImageButton
import android.widget.ListView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.ui.PacketTraceAdapter
class PacketLogActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var adapter: PacketTraceAdapter
private val items = mutableListOf<pro.nnnteam.nnnet.data.PacketTraceSummary>()
private var currentDirection = MeshRepository.TRACE_OUTGOING
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_packet_log)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
adapter = PacketTraceAdapter(this, items)
findViewById<ListView>(R.id.packetListView).adapter = adapter
findViewById<MaterialButton>(R.id.outgoingButton).setOnClickListener {
currentDirection = MeshRepository.TRACE_OUTGOING
refresh()
}
findViewById<MaterialButton>(R.id.incomingButton).setOnClickListener {
currentDirection = MeshRepository.TRACE_INCOMING
refresh()
}
findViewById<MaterialButton>(R.id.relayButton).setOnClickListener {
currentDirection = MeshRepository.TRACE_RELAY
refresh()
}
refresh()
}
private fun refresh() {
lifecycleScope.launch {
val loaded = repository.recentPacketSummaries(currentDirection)
items.clear()
items.addAll(loaded)
adapter.notifyDataSetChanged()
}
}
}

View File

@@ -0,0 +1,67 @@
package pro.nnnteam.nnnet
import android.os.Bundle
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.ui.MapNodeUi
import pro.nnnteam.nnnet.ui.NetworkMapView
class PacketMapActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var mapView: NetworkMapView
private lateinit var mapMetaText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_packet_map)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
mapView = findViewById(R.id.mapView)
mapMetaText = findViewById(R.id.mapMetaText)
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
renderMap()
}
private fun renderMap() {
lifecycleScope.launch {
val local = repository.localProfile()
val remoteNodes = repository.mapNodes()
val nodes = buildList {
add(
MapNodeUi(
label = local?.displayName() ?: getString(R.string.you_label),
peerId = local?.peerId.orEmpty(),
isSelf = true
)
)
remoteNodes
.filter { it.peerId != local?.peerId }
.take(16)
.forEach { profile ->
add(
MapNodeUi(
label = profile.displayName(),
peerId = profile.peerId,
isSelf = false
)
)
}
}
mapView.submitNodes(nodes)
mapMetaText.text = getString(R.string.map_mode_hint, nodes.size)
}
}
}

View File

@@ -0,0 +1,281 @@
package pro.nnnteam.nnnet
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
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.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 android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
class SettingsActivity : AppCompatActivity() {
private lateinit var repository: MeshRepository
private lateinit var firstNameInput: EditText
private lateinit var lastNameInput: EditText
private lateinit var usernameInput: EditText
private lateinit var descriptionInput: EditText
private lateinit var searchInput: EditText
private lateinit var profileResultCard: android.view.View
private lateinit var resultNameText: TextView
private lateinit var resultUsernameText: TextView
private lateinit var resultDescriptionText: TextView
private lateinit var resultPeerIdText: TextView
private lateinit var updateProgressText: TextView
private var receiverRegistered = false
private val prefs by lazy {
getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE)
}
private val meshEventReceiver = object : BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
if (intent?.action != MeshServiceContract.ACTION_EVENT) return
val eventType = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_TYPE) ?: return
if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) {
val query = searchInput.text.toString().trim()
if (query.isNotEmpty()) {
lookupProfile(query)
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
firstNameInput = findViewById(R.id.firstNameInput)
lastNameInput = findViewById(R.id.lastNameInput)
usernameInput = findViewById(R.id.usernameInput)
descriptionInput = findViewById(R.id.descriptionInput)
searchInput = findViewById(R.id.searchInput)
profileResultCard = findViewById(R.id.profileResultCard)
resultNameText = findViewById(R.id.resultNameText)
resultUsernameText = findViewById(R.id.resultUsernameText)
resultDescriptionText = findViewById(R.id.resultDescriptionText)
resultPeerIdText = findViewById(R.id.resultPeerIdText)
updateProgressText = findViewById(R.id.updateProgressText)
val autoUpdateSwitch = findViewById<SwitchMaterial>(R.id.autoUpdateSwitch)
val versionText = findViewById<TextView>(R.id.versionText)
autoUpdateSwitch.isChecked = prefs.getBoolean(UpdateManager.KEY_AUTO_UPDATE, false)
autoUpdateSwitch.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(UpdateManager.KEY_AUTO_UPDATE, isChecked).apply()
}
versionText.text = getString(
R.string.current_version,
packageManager.getPackageInfo(packageName, 0).versionName,
currentVersionCode()
)
findViewById<MaterialButton>(R.id.saveProfileButton).setOnClickListener { saveProfile() }
findViewById<MaterialButton>(R.id.searchButton).setOnClickListener {
val query = searchInput.text.toString().trim()
if (query.isEmpty()) {
Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show()
} else {
lookupProfile(query)
}
}
findViewById<MaterialButton>(R.id.openMapButton).setOnClickListener {
startActivity(Intent(this, PacketMapActivity::class.java))
}
findViewById<MaterialButton>(R.id.openPacketLogButton).setOnClickListener {
startActivity(Intent(this, PacketLogActivity::class.java))
}
findViewById<MaterialButton>(R.id.checkUpdatesButton).setOnClickListener { checkForUpdates() }
loadLocalProfile()
}
override fun onStart() {
super.onStart()
registerMeshReceiver()
}
override fun onStop() {
if (receiverRegistered) {
unregisterReceiver(meshEventReceiver)
receiverRegistered = false
}
super.onStop()
}
private fun registerMeshReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(MeshServiceContract.ACTION_EVENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshEventReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(meshEventReceiver, filter)
}
receiverRegistered = true
}
private fun loadLocalProfile() {
lifecycleScope.launch {
val localProfile = repository.localProfile()
if (localProfile != null) {
firstNameInput.setText(localProfile.firstName)
lastNameInput.setText(localProfile.lastName)
usernameInput.setText(localProfile.username)
descriptionInput.setText(localProfile.description)
}
}
}
private fun saveProfile() {
val firstName = firstNameInput.text.toString().trim()
val lastName = lastNameInput.text.toString().trim()
val username = usernameInput.text.toString().trim().removePrefix("@")
val description = descriptionInput.text.toString().trim()
if (username.isBlank()) {
Toast.makeText(this, R.string.username_required, Toast.LENGTH_SHORT).show()
return
}
lifecycleScope.launch {
repository.saveLocalProfile(
firstName = firstName,
lastName = lastName,
username = username,
description = description
)
Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show()
}
}
private fun lookupProfile(username: String) {
lifecycleScope.launch {
val profile = repository.profileByUsername(username.removePrefix("@"))
renderSearchResult(profile)
if (profile == null) {
Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show()
}
}
}
private fun renderSearchResult(profile: ProfileEntity?) {
if (profile == null) {
profileResultCard.visibility = android.view.View.GONE
return
}
profileResultCard.visibility = android.view.View.VISIBLE
resultNameText.text = profile.displayName()
resultUsernameText.text = "@${profile.username}"
resultDescriptionText.text = profile.description.ifBlank { getString(R.string.no_profile_description) }
resultPeerIdText.text = if (profile.peerId.isBlank()) {
getString(R.string.peer_id_unknown)
} else {
getString(R.string.peer_id_value, profile.peerId)
}
}
private fun checkForUpdates() {
lifecycleScope.launch {
showUpdateProgress(getString(R.string.update_checking))
val updateInfo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
if (updateInfo == null) {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
return@launch
}
if (updateInfo.versionCode > currentVersionCode()) {
showUpdateDialog(updateInfo)
} else {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.latest_version_installed, Toast.LENGTH_SHORT).show()
}
}
}
private fun showUpdateDialog(updateInfo: UpdateInfo) {
lifecycleScope.launch {
val releaseNotes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
UpdateManager.fetchReleaseNotes(updateInfo.releaseNotesPath)
}
androidx.appcompat.app.AlertDialog.Builder(this@SettingsActivity)
.setTitle(updateInfo.releaseNotesTitle)
.setMessage(
buildString {
append(getString(R.string.update_available_message, updateInfo.versionName))
if (!releaseNotes.isNullOrBlank()) {
append("\n\n")
append(releaseNotes.trim())
}
}
)
.setPositiveButton(R.string.download_update) { _, _ ->
downloadAndInstallUpdate(updateInfo)
}
.setNegativeButton(R.string.later, null)
.show()
}
}
private fun downloadAndInstallUpdate(updateInfo: UpdateInfo) {
lifecycleScope.launch {
showUpdateProgress(getString(R.string.update_downloading))
val apkFile = UpdateInstaller.downloadToTempFile(this@SettingsActivity, updateInfo)
if (apkFile == null) {
hideUpdateProgress()
Toast.makeText(this@SettingsActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show()
return@launch
}
showUpdateProgress(getString(R.string.update_installing))
val started = UpdateInstaller.installDownloadedApk(this@SettingsActivity, apkFile)
if (!started) {
hideUpdateProgress()
}
}
}
private fun showUpdateProgress(message: String) {
updateProgressText.visibility = View.VISIBLE
updateProgressText.text = message
}
private fun hideUpdateProgress() {
updateProgressText.visibility = View.GONE
}
private fun currentVersionCode(): Int {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode
}
}
}

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
data class ChatSummary( data class ChatSummary(
val peerId: String, val peerId: String,

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room.Database
@@ -6,13 +6,15 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [MessageEntity::class, OutboundQueueEntity::class], entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
version = 1, version = 3,
exportSchema = false exportSchema = false
) )
abstract class MeshDatabase : RoomDatabase() { abstract class MeshDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao abstract fun messageDao(): MessageDao
abstract fun outboundQueueDao(): OutboundQueueDao abstract fun outboundQueueDao(): OutboundQueueDao
abstract fun profileDao(): ProfileDao
abstract fun packetTraceDao(): PacketTraceDao
companion object { companion object {
@Volatile @Volatile
@@ -24,7 +26,10 @@ abstract class MeshDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
MeshDatabase::class.java, MeshDatabase::class.java,
"mesh.db" "mesh.db"
).build().also { instance = it } )
.fallbackToDestructiveMigration()
.build()
.also { instance = it }
} }
} }
} }

View File

@@ -0,0 +1,264 @@
package pro.nnnteam.nnnet.data
import pro.nnnteam.nnnet.mesh.MeshPacket
import pro.nnnteam.nnnet.mesh.PacketType
import java.util.UUID
class MeshRepository(
private val messageDao: MessageDao,
private val queueDao: OutboundQueueDao,
private val profileDao: ProfileDao,
private val packetTraceDao: PacketTraceDao
) {
suspend fun enqueueOutgoingMessage(
senderId: String,
targetId: String,
body: String,
now: Long = System.currentTimeMillis()
): String {
val messageId = UUID.randomUUID().toString()
messageDao.upsert(
MessageEntity(
messageId = messageId,
senderId = senderId,
targetId = targetId,
body = body,
packetType = PacketType.MESSAGE.name,
direction = DIRECTION_OUTGOING,
status = STATUS_QUEUED,
createdAt = now,
updatedAt = now
)
)
queueDao.upsert(
OutboundQueueEntity(
messageId = messageId,
targetId = targetId,
body = body,
nextAttemptAt = now,
attemptCount = 0,
maxAttempts = DEFAULT_MAX_ATTEMPTS,
createdAt = now
)
)
return messageId
}
suspend fun recordIncomingMessage(packet: MeshPacket, now: Long = System.currentTimeMillis()) {
if (messageDao.findById(packet.messageId) != null) return
messageDao.upsert(
MessageEntity(
messageId = packet.messageId,
senderId = packet.senderId,
targetId = packet.targetId,
body = packet.payload,
packetType = packet.type.name,
direction = DIRECTION_INCOMING,
status = STATUS_DELIVERED,
createdAt = packet.timestamp,
updatedAt = now,
ackedAt = now
)
)
}
suspend fun markAckDelivered(originalMessageId: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(originalMessageId, STATUS_DELIVERED, now, now)
queueDao.delete(originalMessageId)
}
suspend fun markSendAttempt(
messageId: String,
attemptCount: Int,
nextAttemptAt: Long,
error: String? = null,
now: Long = System.currentTimeMillis()
) {
messageDao.updateStatus(messageId, STATUS_SENT, now, null)
queueDao.updateAttempt(messageId, nextAttemptAt, attemptCount, error)
}
suspend fun markFailed(messageId: String, error: String, now: Long = System.currentTimeMillis()) {
messageDao.updateStatus(messageId, STATUS_FAILED, now, null)
queueDao.delete(messageId)
}
suspend fun readyQueue(now: Long = System.currentTimeMillis(), limit: Int = 20): List<OutboundQueueEntity> {
return queueDao.readyToSend(now, limit)
}
suspend fun recentMessages(limit: Int = 50): List<MessageEntity> {
return messageDao.recentMessages(limit)
}
suspend fun chatSummaries(): List<ChatSummary> {
return messageDao.chatSummaries()
}
suspend fun messagesForPeer(peerId: String, limit: Int = 100): List<MessageEntity> {
return messageDao.messagesForPeer(peerId, limit)
}
suspend fun saveLocalProfile(
firstName: String,
lastName: String,
username: String,
description: String,
peerId: String = "",
now: Long = System.currentTimeMillis()
): ProfileEntity {
val normalizedUsername = normalizeUsername(username)
profileDao.deleteLocalProfiles()
val entity = ProfileEntity(
username = normalizedUsername,
firstName = firstName.trim(),
lastName = lastName.trim(),
description = description.trim(),
peerId = peerId,
updatedAt = now,
lastSeenAt = now,
isLocal = true
)
profileDao.upsert(entity)
return entity
}
suspend fun localProfile(): ProfileEntity? = profileDao.localProfile()
suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) {
if (peerId.isBlank()) return
profileDao.updateLocalPeerId(peerId, now, now)
}
suspend fun upsertRemoteProfile(
payload: ProfilePayload,
peerId: String,
now: Long = System.currentTimeMillis()
): ProfileEntity? {
val normalizedUsername = normalizeUsername(payload.username)
if (normalizedUsername.isBlank()) return null
val localProfile = profileDao.localProfile()
if (localProfile?.username == normalizedUsername) {
return localProfile
}
val existing = profileDao.findByUsername(normalizedUsername)
val entity = ProfileEntity(
username = normalizedUsername,
firstName = payload.firstName.trim(),
lastName = payload.lastName.trim(),
description = payload.description.trim(),
peerId = peerId,
updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L),
lastSeenAt = now,
isLocal = false
)
profileDao.upsert(entity)
return entity
}
suspend fun profileByUsername(username: String): ProfileEntity? {
return profileDao.findByUsername(normalizeUsername(username))
}
suspend fun profileByPeerId(peerId: String): ProfileEntity? {
if (peerId.isBlank()) return null
return profileDao.findByPeerId(peerId)
}
suspend fun searchProfiles(query: String, limit: Int = 20): List<ProfileEntity> {
val trimmed = query.trim()
if (trimmed.isBlank()) return emptyList()
val exact = profileByUsername(trimmed)
val fuzzy = profileDao.search("%$trimmed%", limit)
return buildList {
if (exact != null) add(exact)
fuzzy.forEach { candidate ->
if (none { it.username == candidate.username }) {
add(candidate)
}
}
}
}
suspend fun queuedCount(): Int = queueDao.count()
suspend fun recordPacketTrace(
traceDirection: String,
packet: MeshPacket,
relatedPeerId: String = "",
now: Long = System.currentTimeMillis()
) {
packetTraceDao.insert(
PacketTraceEntity(
traceDirection = traceDirection,
packetType = packet.type.name,
messageId = packet.messageId,
senderId = packet.senderId,
targetId = packet.targetId,
relatedPeerId = relatedPeerId,
payloadPreview = packet.payload.take(120),
createdAt = now
)
)
}
suspend fun recentPacketTraces(direction: String, limit: Int = 50): List<PacketTraceEntity> {
return packetTraceDao.recentByDirection(direction, limit)
}
suspend fun recentPacketSummaries(direction: String, limit: Int = 50): List<PacketTraceSummary> {
return packetTraceDao.recentByDirection(direction, limit).map { trace ->
PacketTraceSummary(
title = "${directionLabel(trace.traceDirection)} · ${trace.packetType}",
subtitle = "${trace.senderId}${trace.targetId}",
meta = buildString {
if (trace.relatedPeerId.isNotBlank()) {
append("через ${trace.relatedPeerId} · ")
}
append(trace.payloadPreview)
}
)
}
}
suspend fun mapNodes(limit: Int = 24): List<ProfileEntity> {
val peerIds = packetTraceDao.recentPeerIds(limit)
return peerIds.mapNotNull { peerId ->
profileDao.findByPeerId(peerId) ?: ProfileEntity(
username = peerId.lowercase(),
firstName = "",
lastName = "",
description = "",
peerId = peerId,
updatedAt = 0L,
lastSeenAt = 0L,
isLocal = false
)
}
}
private fun normalizeUsername(value: String): String = value.trim().lowercase()
private fun directionLabel(direction: String): String = when (direction) {
TRACE_OUTGOING -> "Исходящий"
TRACE_INCOMING -> "Входящий"
TRACE_RELAY -> "Транзит"
else -> direction
}
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_SENT = "sent"
const val STATUS_DELIVERED = "delivered"
const val STATUS_FAILED = "failed"
const val DIRECTION_INCOMING = "incoming"
const val DIRECTION_OUTGOING = "outgoing"
const val TRACE_OUTGOING = "outgoing"
const val TRACE_INCOMING = "incoming"
const val TRACE_RELAY = "relay"
private const val DEFAULT_MAX_ATTEMPTS = 5
}
}

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.data package pro.nnnteam.nnnet.data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey

View File

@@ -0,0 +1,41 @@
package pro.nnnteam.nnnet.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface PacketTraceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(trace: PacketTraceEntity)
@Query(
"""
SELECT * FROM packet_traces
WHERE traceDirection = :direction
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun recentByDirection(direction: String, limit: Int): List<PacketTraceEntity>
@Query(
"""
SELECT * FROM packet_traces
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun recent(limit: Int): List<PacketTraceEntity>
@Query(
"""
SELECT DISTINCT relatedPeerId FROM packet_traces
WHERE relatedPeerId != ''
ORDER BY createdAt DESC
LIMIT :limit
"""
)
suspend fun recentPeerIds(limit: Int): List<String>
}

View File

@@ -0,0 +1,17 @@
package pro.nnnteam.nnnet.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "packet_traces")
data class PacketTraceEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val traceDirection: String,
val packetType: String,
val messageId: String,
val senderId: String,
val targetId: String,
val relatedPeerId: String,
val payloadPreview: String,
val createdAt: Long
)

View File

@@ -0,0 +1,7 @@
package pro.nnnteam.nnnet.data
data class PacketTraceSummary(
val title: String,
val subtitle: String,
val meta: String
)

View File

@@ -0,0 +1,37 @@
package pro.nnnteam.nnnet.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ProfileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(profile: ProfileEntity)
@Query("SELECT * FROM profiles WHERE isLocal = 1 LIMIT 1")
suspend fun localProfile(): ProfileEntity?
@Query("DELETE FROM profiles WHERE isLocal = 1")
suspend fun deleteLocalProfiles()
@Query("SELECT * FROM profiles WHERE username = :username LIMIT 1")
suspend fun findByUsername(username: String): ProfileEntity?
@Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1")
suspend fun findByPeerId(peerId: String): ProfileEntity?
@Query(
"""
SELECT * FROM profiles
WHERE username LIKE :query OR firstName LIKE :query OR lastName LIKE :query
ORDER BY isLocal DESC, updatedAt DESC
LIMIT :limit
"""
)
suspend fun search(query: String, limit: Int): List<ProfileEntity>
@Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1")
suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long)
}

View File

@@ -0,0 +1,32 @@
package pro.nnnteam.nnnet.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "profiles")
data class ProfileEntity(
@PrimaryKey val username: String,
val firstName: String,
val lastName: String,
val description: String,
val peerId: String,
val updatedAt: Long,
val lastSeenAt: Long,
val isLocal: Boolean
) {
fun displayName(): String {
val fullName = listOf(firstName, lastName)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(" ")
return fullName.ifBlank { "@$username" }
}
fun metaLine(): String {
return if (peerId.isBlank()) {
"@$username"
} else {
"@$username · $peerId"
}
}
}

View File

@@ -0,0 +1,36 @@
package pro.nnnteam.nnnet.data
import org.json.JSONObject
data class ProfilePayload(
val firstName: String,
val lastName: String,
val username: String,
val description: String,
val updatedAt: Long
) {
fun normalizedUsername(): String = username.trim().lowercase()
}
object ProfilePayloadCodec {
fun encode(payload: ProfilePayload): String {
return JSONObject()
.put("firstName", payload.firstName)
.put("lastName", payload.lastName)
.put("username", payload.username)
.put("description", payload.description)
.put("updatedAt", payload.updatedAt)
.toString()
}
fun decode(raw: String): ProfilePayload {
val json = JSONObject(raw)
return ProfilePayload(
firstName = json.optString("firstName", "").trim(),
lastName = json.optString("lastName", "").trim(),
username = json.getString("username").trim(),
description = json.optString("description", "").trim(),
updatedAt = json.optLong("updatedAt", System.currentTimeMillis())
)
}
}

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -7,11 +7,12 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothGattServer import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothGattServerCallback import android.bluetooth.BluetoothGattServerCallback
import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings import android.bluetooth.le.AdvertiseSettings
@@ -21,7 +22,6 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.bluetooth.BluetoothStatusCodes
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
@@ -38,6 +38,10 @@ class BleMeshManager(
private val onStatusChanged: (String) -> Unit = {}, private val onStatusChanged: (String) -> Unit = {},
private val onAckReceived: (String) -> Unit = {}, private val onAckReceived: (String) -> Unit = {},
private val onMessageReceived: (MeshPacket) -> Unit = {}, private val onMessageReceived: (MeshPacket) -> Unit = {},
private val onProfileReceived: (MeshPacket) -> Unit = {},
private val onPacketOutgoing: (MeshPacket, String) -> Unit = { _, _ -> },
private val onPacketIncoming: (MeshPacket, String) -> Unit = { _, _ -> },
private val onPacketRelay: (MeshPacket, String) -> Unit = { _, _ -> },
private val onError: (String) -> Unit = {}, private val onError: (String) -> Unit = {},
private val onLog: (String) -> Unit = {}, private val onLog: (String) -> Unit = {},
private val seenPacketCache: SeenPacketCache = SeenPacketCache() private val seenPacketCache: SeenPacketCache = SeenPacketCache()
@@ -69,22 +73,22 @@ class BleMeshManager(
if (address == localNodeId || activeConnections.containsKey(address)) { if (address == localNodeId || activeConnections.containsKey(address)) {
return return
} }
log("Discovered BLE node: $address") log("Обнаружен BLE-узел: $address")
connectToPeer(device) connectToPeer(device)
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
fail("BLE scan failed: $errorCode") fail("Ошибка BLE-сканирования: $errorCode")
} }
} }
private val advertiseCallback = object : AdvertiseCallback() { private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
log("BLE advertising started") log("Запущен BLE advertising")
} }
override fun onStartFailure(errorCode: Int) { override fun onStartFailure(errorCode: Int) {
fail("BLE advertising failed: $errorCode") fail("Ошибка BLE advertising: $errorCode")
} }
} }
@@ -110,7 +114,7 @@ class BleMeshManager(
} }
val rawPacket = value.toString(StandardCharsets.UTF_8) val rawPacket = value.toString(StandardCharsets.UTF_8)
log("Packet received from ${device.address}: $rawPacket") log("Пакет получен от ${device.address}: $rawPacket")
handleIncomingPacket(rawPacket) handleIncomingPacket(rawPacket)
if (responseNeeded) { if (responseNeeded) {
@@ -125,20 +129,20 @@ class BleMeshManager(
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val address = device.address ?: return val address = device.address ?: return
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
log("GATT client error for $address: status=$status") log("Ошибка GATT-клиента для $address: status=$status")
closeConnection(address) closeConnection(address)
return return
} }
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
log("Connected to peer $address") log("Подключено к узлу $address")
activeConnections[address] = gatt activeConnections[address] = gatt
gatt.discoverServices() gatt.discoverServices()
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
log("Disconnected from peer $address") log("Узел отключился: $address")
closeConnection(address) closeConnection(address)
} }
} }
@@ -146,11 +150,12 @@ class BleMeshManager(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
log("Service discovery failed for ${device.address}: $status") log("Не удалось обнаружить сервисы у ${device.address}: $status")
return return
} }
log("Services discovered for ${device.address}") log("Сервисы обнаружены у ${device.address}")
sendPresence(gatt) sendPresence(gatt)
device.address?.let(onPeerDiscovered)
} }
override fun onCharacteristicWrite( override fun onCharacteristicWrite(
@@ -160,9 +165,9 @@ class BleMeshManager(
) { ) {
val address = device.address ?: return val address = device.address ?: return
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
log("Packet sent to $address") log("Пакет отправлен на $address")
} else { } else {
log("Packet send failed to $address: status=$status") log("Ошибка отправки пакета на $address: status=$status")
} }
} }
} }
@@ -178,22 +183,37 @@ class BleMeshManager(
} }
return when (packet.type) { return when (packet.type) {
PacketType.ACK -> MeshAction.ConsumeAck(packet.payload) PacketType.ACK -> {
if (packet.targetId == localNodeId) {
MeshAction.ConsumeAck(packet.payload)
} else {
MeshAction.Relay(packet.decrementedTtl())
}
}
PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId) PacketType.PRESENCE -> MeshAction.ConsumePresence(packet.senderId)
PacketType.MESSAGE -> MeshAction.ProcessAndRelay(packet.decrementedTtl()) PacketType.MESSAGE -> {
if (packet.targetId == localNodeId) {
MeshAction.DeliverMessage(packet)
} else {
MeshAction.Relay(packet.decrementedTtl())
}
}
PacketType.PROFILE -> MeshAction.CacheProfile(packet, packet.decrementedTtl())
} }
} }
fun start() { fun start() {
if (isRunning) return if (isRunning) return
if (!hasRequiredRuntimePermissions()) { if (!hasRequiredRuntimePermissions()) {
fail("BLE permissions are missing") fail("Не выданы BLE-разрешения")
return return
} }
val adapter = bluetoothAdapter val adapter = bluetoothAdapter
if (adapter == null || !adapter.isEnabled) { if (adapter == null || !adapter.isEnabled) {
fail("Bluetooth adapter is unavailable or disabled") fail("Bluetooth недоступен или выключен")
return return
} }
@@ -201,7 +221,7 @@ class BleMeshManager(
startScanning() startScanning()
startAdvertising() startAdvertising()
isRunning = true isRunning = true
onStatusChanged("Mesh активен, идет discovery и GATT transport") onStatusChanged("NNNet в сети, поиск соседей и транспорт GATT активны")
log("BLE mesh manager started with nodeId=$localNodeId") log("BLE mesh manager started with nodeId=$localNodeId")
} }
@@ -219,50 +239,66 @@ class BleMeshManager(
inboundCharacteristic = null inboundCharacteristic = null
gattServer = null gattServer = null
isRunning = false isRunning = false
onStatusChanged("Mesh остановлен") onStatusChanged("NNNet оффлайн")
log("BLE mesh manager stopped") log("BLE mesh manager stopped")
} }
private fun handleIncomingPacket(rawPacket: String) { private fun handleIncomingPacket(rawPacket: String) {
val packet = runCatching { MeshPacketCodec.decode(rawPacket) } val packet = runCatching { MeshPacketCodec.decode(rawPacket) }
.getOrElse { .getOrElse {
fail("Packet decode failed: ${it.message}") fail("Не удалось декодировать пакет: ${it.message}")
return return
} }
when (val action = onPacketReceived(packet)) { when (val action = onPacketReceived(packet)) {
MeshAction.DropDuplicate -> log("Duplicate packet dropped: ${packet.messageId}") MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}")
MeshAction.DropExpired -> log("Expired packet dropped: ${packet.messageId}") MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}")
is MeshAction.ConsumeAck -> { is MeshAction.ConsumeAck -> {
onPacketIncoming(packet, packet.senderId)
onAckReceived(action.messageId) onAckReceived(action.messageId)
log("ACK consumed: ${action.messageId}") log("ACK обработан: ${action.messageId}")
} }
is MeshAction.ConsumePresence -> { is MeshAction.ConsumePresence -> {
onPacketIncoming(packet, packet.senderId)
onPeerDiscovered(action.senderId) onPeerDiscovered(action.senderId)
onStatusChanged("Presence from ${action.senderId}") onStatusChanged("Устройство ${action.senderId} рядом")
log("Presence consumed from ${action.senderId}") log("Сигнал присутствия обработан от ${action.senderId}")
} }
is MeshAction.DeliverMessage -> {
onPacketIncoming(action.packet, action.packet.senderId)
onMessageReceived(action.packet)
onStatusChanged("Новое сообщение от ${action.packet.senderId}")
sendAck(action.packet)
}
is MeshAction.CacheProfile -> {
onPacketRelay(action.packet, action.packet.senderId)
onProfileReceived(action.packet)
broadcastIfAlive(action.packetToRelay)
}
is MeshAction.Relay -> {
onPacketRelay(action.packetToRelay, action.packetToRelay.senderId)
log("Ретрансляция пакета ${action.packetToRelay.messageId}")
broadcastIfAlive(action.packetToRelay)
}
}
}
is MeshAction.ProcessAndRelay -> { private fun broadcastIfAlive(packet: MeshPacket) {
onMessageReceived(packet) if (!packet.isExpired()) {
onStatusChanged("Message from ${packet.senderId}") broadcastPacket(packet)
log("Relaying packet ${packet.messageId}")
broadcastPacket(action.packetToRelay)
sendAck(packet)
}
} }
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startGattServer() { private fun startGattServer() {
val manager = bluetoothManager ?: run { val manager = bluetoothManager ?: run {
fail("BluetoothManager unavailable") fail("BluetoothManager недоступен")
return return
} }
val server = manager.openGattServer(context, gattServerCallback) val server = manager.openGattServer(context, gattServerCallback)
if (server == null) { if (server == null) {
fail("Failed to open GATT server") fail("Не удалось открыть GATT server")
return return
} }
@@ -318,7 +354,7 @@ class BleMeshManager(
private fun connectToPeer(device: BluetoothDevice) { private fun connectToPeer(device: BluetoothDevice) {
val address = device.address ?: return val address = device.address ?: return
if (activeConnections.containsKey(address)) return if (activeConnections.containsKey(address)) return
log("Connecting to peer $address") log("Подключение к узлу $address")
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE) device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE)
} else { } else {
@@ -333,10 +369,11 @@ class BleMeshManager(
private fun sendPresence(gatt: BluetoothGatt) { private fun sendPresence(gatt: BluetoothGatt) {
val packet = MeshPacket( val packet = MeshPacket(
senderId = localNodeId, senderId = localNodeId,
targetId = gatt.device.address ?: "broadcast", targetId = gatt.device.address ?: BROADCAST_TARGET,
type = PacketType.PRESENCE, type = PacketType.PRESENCE,
payload = "presence:$localNodeId" payload = "presence:$localNodeId"
) )
onPacketOutgoing(packet, gatt.device.address ?: "")
writePacket(gatt, packet) writePacket(gatt, packet)
} }
@@ -347,6 +384,7 @@ class BleMeshManager(
type = PacketType.ACK, type = PacketType.ACK,
payload = packet.messageId payload = packet.messageId
) )
onPacketOutgoing(ack, packet.senderId)
broadcastPacket(ack) broadcastPacket(ack)
} }
@@ -359,6 +397,7 @@ class BleMeshManager(
} }
fun sendPacket(packet: MeshPacket): Boolean { fun sendPacket(packet: MeshPacket): Boolean {
onPacketOutgoing(packet, packet.targetId)
val directedGatt = activeConnections[packet.targetId] val directedGatt = activeConnections[packet.targetId]
return if (directedGatt != null) { return if (directedGatt != null) {
writePacket(directedGatt, packet) writePacket(directedGatt, packet)
@@ -374,7 +413,7 @@ class BleMeshManager(
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID) ?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
if (characteristic == null) { if (characteristic == null) {
log("Remote characteristic missing on ${gatt.device.address}") log("У удалённого узла нет mesh-характеристики: ${gatt.device.address}")
return false return false
} }
@@ -435,6 +474,7 @@ class BleMeshManager(
} }
companion object { companion object {
const val BROADCAST_TARGET = "*"
private const val TAG = "BleMeshManager" private const val TAG = "BleMeshManager"
private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b") private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b")
private val CHARACTERISTIC_PACKET_UUID: UUID = private val CHARACTERISTIC_PACKET_UUID: UUID =
@@ -447,5 +487,7 @@ sealed interface MeshAction {
data object DropExpired : MeshAction data object DropExpired : MeshAction
data class ConsumeAck(val messageId: String) : MeshAction data class ConsumeAck(val messageId: String) : MeshAction
data class ConsumePresence(val senderId: String) : MeshAction data class ConsumePresence(val senderId: String) : MeshAction
data class ProcessAndRelay(val packetToRelay: MeshPacket) : MeshAction data class DeliverMessage(val packet: MeshPacket) : MeshAction
data class CacheProfile(val packet: MeshPacket, val packetToRelay: MeshPacket) : MeshAction
data class Relay(val packetToRelay: MeshPacket) : MeshAction
} }

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -9,32 +9,43 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.schoolmesh.messenger.R
import com.schoolmesh.messenger.data.MeshDatabase
import com.schoolmesh.messenger.data.MeshRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.MeshDatabase
import pro.nnnteam.nnnet.data.MeshRepository
import pro.nnnteam.nnnet.data.ProfilePayload
import pro.nnnteam.nnnet.data.ProfilePayloadCodec
class MeshForegroundService : Service() { class MeshForegroundService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var bleMeshManager: BleMeshManager private lateinit var bleMeshManager: BleMeshManager
private lateinit var repository: MeshRepository private lateinit var repository: MeshRepository
private lateinit var queueProcessor: MeshQueueProcessor private lateinit var queueProcessor: MeshQueueProcessor
private var lastProfileBroadcastAt = 0L
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
val database = MeshDatabase.getInstance(applicationContext) val database = MeshDatabase.getInstance(applicationContext)
repository = MeshRepository(database.messageDao(), database.outboundQueueDao()) repository = MeshRepository(
database.messageDao(),
database.outboundQueueDao(),
database.profileDao(),
database.packetTraceDao()
)
bleMeshManager = BleMeshManager( bleMeshManager = BleMeshManager(
context = applicationContext, context = applicationContext,
onPeerDiscovered = { address -> onPeerDiscovered = { address ->
sendEvent(MeshServiceContract.EVENT_PEER, address) sendEvent(MeshServiceContract.EVENT_PEER, address)
sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address") sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
queueProcessor.poke() queueProcessor.poke()
serviceScope.launch {
publishLocalProfile(force = false)
}
}, },
onStatusChanged = { status -> onStatusChanged = { status ->
sendEvent(MeshServiceContract.EVENT_STATUS, status) sendEvent(MeshServiceContract.EVENT_STATUS, status)
@@ -43,21 +54,46 @@ class MeshForegroundService : Service() {
onAckReceived = { messageId -> onAckReceived = { messageId ->
serviceScope.launch { serviceScope.launch {
repository.markAckDelivered(messageId) repository.markAckDelivered(messageId)
sendEvent(MeshServiceContract.EVENT_LOG, "ACK delivered for $messageId") sendEvent(MeshServiceContract.EVENT_LOG, "ACK получен для $messageId")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId) sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
} }
}, },
onMessageReceived = { packet -> onMessageReceived = { packet ->
serviceScope.launch { serviceScope.launch {
repository.recordIncomingMessage(packet) repository.recordIncomingMessage(packet)
sendEvent(MeshServiceContract.EVENT_LOG, "Message stored from ${packet.senderId}") sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение сохранено от ${packet.senderId}")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId) sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
} }
}, },
onProfileReceived = { packet ->
serviceScope.launch {
val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch
val stored = repository.upsertRemoteProfile(payload, packet.senderId)
if (stored != null) {
sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}")
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.username)
}
}
},
onPacketOutgoing = { packet, relatedPeerId ->
serviceScope.launch {
repository.recordPacketTrace(MeshRepository.TRACE_OUTGOING, packet, relatedPeerId)
}
},
onPacketIncoming = { packet, relatedPeerId ->
serviceScope.launch {
repository.recordPacketTrace(MeshRepository.TRACE_INCOMING, packet, relatedPeerId)
}
},
onPacketRelay = { packet, relatedPeerId ->
serviceScope.launch {
repository.recordPacketTrace(MeshRepository.TRACE_RELAY, packet, relatedPeerId)
}
},
onError = { message -> onError = { message ->
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message") sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message") sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
updateNotification("Ошибка mesh") updateNotification("Ошибка сети")
}, },
onLog = { message -> onLog = { message ->
sendEvent(MeshServiceContract.EVENT_LOG, message) sendEvent(MeshServiceContract.EVENT_LOG, message)
@@ -90,10 +126,45 @@ class MeshForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
private fun startMesh() { private fun startMesh() {
startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается")) startForeground(NOTIFICATION_ID, buildNotification("NNNet запускает сеть"))
bleMeshManager.start() bleMeshManager.start()
queueProcessor.start() queueProcessor.start()
queueProcessor.poke() queueProcessor.poke()
serviceScope.launch {
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
publishLocalProfile(force = true)
}
}
private suspend fun publishLocalProfile(force: Boolean) {
val now = System.currentTimeMillis()
if (!force && now - lastProfileBroadcastAt < PROFILE_BROADCAST_THROTTLE_MS) {
return
}
repository.updateLocalProfilePeerId(bleMeshManager.nodeId)
val localProfile = repository.localProfile() ?: return
val payload = ProfilePayload(
firstName = localProfile.firstName,
lastName = localProfile.lastName,
username = localProfile.username,
description = localProfile.description,
updatedAt = localProfile.updatedAt
)
val sent = bleMeshManager.sendPacket(
MeshPacket(
senderId = bleMeshManager.nodeId,
targetId = BleMeshManager.BROADCAST_TARGET,
type = PacketType.PROFILE,
payload = ProfilePayloadCodec.encode(payload)
)
)
if (sent) {
lastProfileBroadcastAt = now
}
if (sent || force) {
sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, localProfile.username)
}
} }
private fun stopMesh() { private fun stopMesh() {
@@ -107,7 +178,7 @@ class MeshForegroundService : Service() {
val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty() val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty()
val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty() val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty()
if (targetId.isEmpty() || messageBody.isEmpty()) { if (targetId.isEmpty() || messageBody.isEmpty()) {
sendEvent(MeshServiceContract.EVENT_LOG, "Cannot enqueue empty target/body") sendEvent(MeshServiceContract.EVENT_LOG, "Нельзя поставить в очередь пустое сообщение")
return return
} }
@@ -117,7 +188,7 @@ class MeshForegroundService : Service() {
targetId = targetId, targetId = targetId,
body = messageBody body = messageBody
) )
sendEvent(MeshServiceContract.EVENT_LOG, "Message queued: $messageId") sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение поставлено в очередь: $messageId")
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId) sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
queueProcessor.poke() queueProcessor.poke()
} }
@@ -160,6 +231,7 @@ class MeshForegroundService : Service() {
companion object { companion object {
private const val CHANNEL_ID = "mesh_status" private const val CHANNEL_ID = "mesh_status"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val PROFILE_BROADCAST_THROTTLE_MS = 5_000L
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { val intent = Intent(context, MeshForegroundService::class.java).apply {

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
import java.util.UUID import java.util.UUID

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
import org.json.JSONObject import org.json.JSONObject

View File

@@ -1,6 +1,6 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
import com.schoolmesh.messenger.data.MeshRepository import pro.nnnteam.nnnet.data.MeshRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay

View File

@@ -1,10 +1,10 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
object MeshServiceContract { object MeshServiceContract {
const val ACTION_START = "com.schoolmesh.messenger.mesh.START" const val ACTION_START = "pro.nnnteam.nnnet.mesh.START"
const val ACTION_STOP = "com.schoolmesh.messenger.mesh.STOP" const val ACTION_STOP = "pro.nnnteam.nnnet.mesh.STOP"
const val ACTION_SEND_MESSAGE = "com.schoolmesh.messenger.mesh.SEND_MESSAGE" const val ACTION_SEND_MESSAGE = "pro.nnnteam.nnnet.mesh.SEND_MESSAGE"
const val ACTION_EVENT = "com.schoolmesh.messenger.mesh.EVENT" const val ACTION_EVENT = "pro.nnnteam.nnnet.mesh.EVENT"
const val EXTRA_EVENT_TYPE = "event_type" const val EXTRA_EVENT_TYPE = "event_type"
const val EXTRA_EVENT_VALUE = "event_value" const val EXTRA_EVENT_VALUE = "event_value"
@@ -15,4 +15,5 @@ object MeshServiceContract {
const val EVENT_PEER = "peer" const val EVENT_PEER = "peer"
const val EVENT_LOG = "log" const val EVENT_LOG = "log"
const val EVENT_MESSAGES_CHANGED = "messages_changed" const val EVENT_MESSAGES_CHANGED = "messages_changed"
const val EVENT_PROFILES_CHANGED = "profiles_changed"
} }

View File

@@ -0,0 +1,8 @@
package pro.nnnteam.nnnet.mesh
enum class PacketType {
MESSAGE,
ACK,
PRESENCE,
PROFILE
}

View File

@@ -1,4 +1,4 @@
package com.schoolmesh.messenger.mesh package pro.nnnteam.nnnet.mesh
class SeenPacketCache( class SeenPacketCache(
private val maxSize: Int = 512 private val maxSize: Int = 512

View File

@@ -0,0 +1,49 @@
package pro.nnnteam.nnnet.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import pro.nnnteam.nnnet.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ChatListAdapter(
context: Context,
private val items: MutableList<ChatListItem>
) : BaseAdapter() {
private val inflater = LayoutInflater.from(context)
private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru"))
override fun getCount(): Int = items.size
override fun getItem(position: Int): ChatListItem = items[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: inflater.inflate(R.layout.item_chat_summary, parent, false)
val item = getItem(position)
view.findViewById<TextView>(R.id.avatarText).text = avatarLetter(item.title)
view.findViewById<TextView>(R.id.chatNameText).text = item.title
view.findViewById<TextView>(R.id.chatPreviewText).text = item.subtitle
view.findViewById<TextView>(R.id.chatTimeText).text = timeFormatter.format(Date(item.lastTimestamp))
view.findViewById<TextView>(R.id.chatStatusText).text = statusLabel(item.lastStatus)
return view
}
private fun avatarLetter(title: String): String = title.firstOrNull()?.uppercase() ?: "N"
private fun statusLabel(status: String): String = when (status) {
"queued" -> "В очереди"
"sent" -> "Отправлено"
"delivered" -> "Доставлено"
"failed" -> "Ошибка"
else -> status
}
}

View File

@@ -0,0 +1,9 @@
package pro.nnnteam.nnnet.ui
data class ChatListItem(
val peerId: String,
val title: String,
val subtitle: String,
val lastStatus: String,
val lastTimestamp: Long
)

View File

@@ -0,0 +1,71 @@
package pro.nnnteam.nnnet.ui
import android.content.Context
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.MessageEntity
import pro.nnnteam.nnnet.data.MeshRepository
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MessageListAdapter(
context: Context,
private val items: MutableList<MessageEntity>
) : BaseAdapter() {
private val inflater = LayoutInflater.from(context)
private val timeFormatter = SimpleDateFormat("HH:mm", Locale("ru"))
override fun getCount(): Int = items.size
override fun getItem(position: Int): MessageEntity = items[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: inflater.inflate(R.layout.item_message, parent, false)
val item = getItem(position)
val bubble = view.findViewById<LinearLayout>(R.id.messageBubble)
val container = view.findViewById<FrameLayout>(R.id.messageContainer)
val bodyText = view.findViewById<TextView>(R.id.messageBodyText)
val metaText = view.findViewById<TextView>(R.id.messageMetaText)
val isOutgoing = item.direction == MeshRepository.DIRECTION_OUTGOING
val params = bubble.layoutParams as FrameLayout.LayoutParams
params.gravity = if (isOutgoing) Gravity.END else Gravity.START
bubble.layoutParams = params
bubble.background = ContextCompat.getDrawable(
view.context,
if (isOutgoing) R.drawable.bg_message_outgoing else R.drawable.bg_message_incoming
)
container.foreground = null
bodyText.text = item.body
metaText.text = buildString {
append(timeFormatter.format(Date(item.createdAt)))
append(" · ")
append(statusLabel(item.status, isOutgoing))
}
metaText.gravity = if (isOutgoing) Gravity.END else Gravity.START
return view
}
private fun statusLabel(status: String, isOutgoing: Boolean): String {
if (!isOutgoing) return "Получено"
return when (status) {
"queued" -> "В очереди"
"sent" -> "Отправлено"
"delivered" -> "Доставлено"
"failed" -> "Ошибка отправки"
else -> status
}
}
}

View File

@@ -0,0 +1,73 @@
package pro.nnnteam.nnnet.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
class NetworkMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#4C9EEB")
style = Paint.Style.FILL
}
private val selfPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#33A56E")
style = Paint.Style.FILL
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#1E2B37")
textSize = 34f
textAlign = Paint.Align.CENTER
}
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#A7B8C7")
strokeWidth = 4f
}
private var nodes: List<MapNodeUi> = emptyList()
fun submitNodes(items: List<MapNodeUi>) {
nodes = items
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (nodes.isEmpty()) return
val cx = width / 2f
val cy = height / 2f
val radius = min(width, height) * 0.32f
val selfNode = nodes.first()
canvas.drawCircle(cx, cy, 52f, selfPaint)
canvas.drawText(selfNode.label, cx, cy + 86f, textPaint)
val others = nodes.drop(1)
if (others.isEmpty()) return
others.forEachIndexed { index, node ->
val angle = (2 * PI * index / others.size) - PI / 2
val nx = cx + (radius * cos(angle)).toFloat()
val ny = cy + (radius * sin(angle)).toFloat()
canvas.drawLine(cx, cy, nx, ny, linePaint)
canvas.drawCircle(nx, ny, 42f, nodePaint)
canvas.drawText(node.label, nx, ny + 76f, textPaint)
}
}
}
data class MapNodeUi(
val label: String,
val peerId: String,
val isSelf: Boolean
)

View File

@@ -0,0 +1,32 @@
package pro.nnnteam.nnnet.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.data.PacketTraceSummary
class PacketTraceAdapter(
context: Context,
private val items: MutableList<PacketTraceSummary>
) : BaseAdapter() {
private val inflater = LayoutInflater.from(context)
override fun getCount(): Int = items.size
override fun getItem(position: Int): PacketTraceSummary = items[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: inflater.inflate(R.layout.item_packet_trace, parent, false)
val item = getItem(position)
view.findViewById<TextView>(R.id.traceTitleText).text = item.title
view.findViewById<TextView>(R.id.traceSubtitleText).text = item.subtitle
view.findViewById<TextView>(R.id.traceMetaText).text = item.meta
return view
}
}

View File

@@ -0,0 +1,72 @@
package pro.nnnteam.nnnet.update
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pro.nnnteam.nnnet.R
import pro.nnnteam.nnnet.mesh.MeshForegroundService
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
object UpdateInstaller {
suspend fun downloadToTempFile(context: Context, updateInfo: UpdateInfo): File? {
return withContext(Dispatchers.IO) {
runCatching {
val updatesDir = File(context.cacheDir, "updates").apply { mkdirs() }
val apkFile = File(updatesDir, "nnnet-update-${updateInfo.versionName}.apk")
val connection = URL(UpdateManager.buildDownloadUrl(updateInfo.apkPath)).openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
connection.inputStream.use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
apkFile
}.getOrNull()
}
}
fun installDownloadedApk(context: Context, apkFile: File, stopMesh: Boolean = true): Boolean {
if (!apkFile.exists()) return false
if (stopMesh) {
MeshForegroundService.stop(context)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Toast.makeText(context, R.string.allow_unknown_apps, Toast.LENGTH_LONG).show()
return false
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.installer_not_found, Toast.LENGTH_SHORT).show()
false
}
}
}

View File

@@ -0,0 +1,53 @@
package pro.nnnteam.nnnet.update
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
object UpdateManager {
const val PREFS_NAME = "nnnet_settings"
const val KEY_AUTO_UPDATE = "auto_update"
private const val BASE_URL = "https://net.nnn-team.pro"
private const val METADATA_URL = "$BASE_URL/assets/meta/version.json"
fun fetchUpdateInfo(): UpdateInfo? {
return runCatching {
val connection = URL(METADATA_URL).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { reader ->
val json = JSONObject(reader.readText())
UpdateInfo(
versionCode = json.getInt("versionCode"),
versionName = json.getString("versionName"),
apkPath = json.getString("apkPath"),
releaseNotesTitle = json.optString("releaseNotesTitle", "Что нового"),
releaseNotesPath = json.optString("releaseNotesPath", "")
)
}
}.getOrNull()
}
fun fetchReleaseNotes(path: String): String? {
if (path.isBlank()) return null
val url = buildDownloadUrl(path)
return runCatching {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.inputStream.bufferedReader().use { it.readText() }
}.getOrNull()
}
fun buildDownloadUrl(path: String): String {
return if (path.startsWith("http")) path else "$BASE_URL/${path.trimStart('/')}"
}
}
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val apkPath: String,
val releaseNotesTitle: String,
val releaseNotesPath: String
)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#4E8DF5" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E4FFC7" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#4C9EEB" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#5F7488" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#33A56E" />
<corners android:radius="18dp" />
</shape>

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/chat_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="12dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/chatTitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatSubtitleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/top_bar_secondary_text"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView
android:id="@+id/messageListView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="@android:color/transparent"
android:dividerHeight="8dp"
android:listSelector="@android:color/transparent"
android:paddingStart="10dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp"
android:scrollbars="none"
android:transcriptMode="alwaysScroll" />
<TextView
android:id="@+id/emptyStateText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="24dp"
android:text="@string/no_messages"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/message_input_panel"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_message_input"
android:hint="@string/message_hint"
android:maxLines="5"
android:minHeight="48dp"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="10dp"
android:background="@drawable/bg_send_button"
android:contentDescription="@string/send"
android:padding="12dp"
android:src="@android:drawable/ic_menu_send"
android:tint="@android:color/white" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,242 +1,124 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="#18222D" android:background="@color/screen_background">
android:orientation="vertical"
tools:context=".MainActivity">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:background="#233040"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp">
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="NNNet"
android:textColor="#F3F7FB"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#2F6EA5"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:text="Offline"
android:textColor="#FFFFFF" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<Button
android:id="@+id/btnTabChats"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="Chats" />
<Button
android:id="@+id/btnTabSettings"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Settings" />
</LinearLayout>
<LinearLayout
android:id="@+id/chatsScreen"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="18dp"
android:paddingEnd="8dp"
android:paddingBottom="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="NNNet"
android:textColor="@android:color/white"
android:textSize="23sp"
android:textStyle="bold" />
<TextView
android:id="@+id/deviceCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:textColor="@color/top_bar_secondary_text"
android:textSize="13sp" />
</LinearLayout>
<FrameLayout
android:id="@+id/statusBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_status_offline"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:paddingHorizontal="14dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/statusBadgeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="13sp"
android:textStyle="bold" />
</FrameLayout>
<ImageButton
android:id="@+id/menuButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/menu"
android:padding="8dp"
android:src="@android:drawable/ic_menu_more"
android:tint="@android:color/white" />
</LinearLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#121A23" android:background="@color/top_bar_background"
android:paddingHorizontal="16dp" android:paddingStart="16dp"
android:paddingVertical="10dp" android:paddingBottom="14dp"
android:text="Chats" android:text="@string/chats_title"
android:textColor="#8FA1B3" android:textColor="@color/top_bar_secondary_text"
android:textStyle="bold" /> android:textSize="14sp" />
<ListView <ListView
android:id="@+id/chatListView" android:id="@+id/chatListView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#10161E"
android:divider="#1A2531"
android:dividerHeight="1dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#1F2C39"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/activeChatTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Select a chat"
android:textColor="#F3F7FB"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/peersText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Nearby peers will appear here"
android:textColor="#8FA1B3" />
</LinearLayout>
<ListView
android:id="@+id/messageListView"
android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:background="#0E1621" android:background="@color/screen_background"
android:divider="@android:color/transparent" android:divider="@color/chat_divider"
android:dividerHeight="8dp" android:dividerHeight="1dp"
android:padding="12dp" android:listSelector="@android:color/transparent"
android:transcriptMode="alwaysScroll" /> android:paddingTop="4dp"
android:paddingBottom="92dp"
android:scrollbars="none" />
<LinearLayout <TextView
android:id="@+id/emptyStateText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="#18222D" android:layout_gravity="center"
android:orientation="vertical" android:gravity="center"
android:padding="12dp"> android:padding="24dp"
android:text="@string/no_chats"
<EditText android:textColor="@color/secondary_text"
android:id="@+id/targetInput" android:visibility="gone" />
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#233040"
android:hint="Peer ID"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#233040"
android:hint="Write a message"
android:minLines="2"
android:padding="12dp"
android:textColor="#F3F7FB"
android:textColorHint="#8FA1B3" />
<Button
android:id="@+id/btnSendMessage"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
android:text="Send" />
</LinearLayout>
</LinearLayout>
</LinearLayout> </LinearLayout>
<ScrollView <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/settingsScreen" android:id="@+id/newChatButton"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_gravity="bottom|end"
android:visibility="gone"> android:layout_margin="20dp"
android:contentDescription="@string/new_chat"
<LinearLayout android:src="@android:drawable/ic_input_add"
android:layout_width="match_parent" app:backgroundTint="@color/fab_background"
android:layout_height="wrap_content" app:tint="@android:color/white" />
android:orientation="vertical" </FrameLayout>
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings"
android:textColor="#F3F7FB"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Enable auto update checks"
android:textColor="#F3F7FB"
app:useMaterialThemeColors="false" />
<Button
android:id="@+id/btnCheckUpdates"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Check updates now" />
<Button
android:id="@+id/btnStartMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Start mesh" />
<Button
android:id="@+id/btnStopMesh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Stop mesh" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Event log"
android:textColor="#8FA1B3"
android:textStyle="bold" />
<TextView
android:id="@+id/logsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#101820"
android:padding="12dp"
android:text="Log is empty"
android:textColor="#EAF7F2"
android:textIsSelectable="true" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/packet_log_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/outgoingButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/outgoing_packets" />
<com.google.android.material.button.MaterialButton
android:id="@+id/incomingButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/incoming_packets" />
<com.google.android.material.button.MaterialButton
android:id="@+id/relayButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/relay_packets" />
</LinearLayout>
<ListView
android:id="@+id/packetListView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:divider="@color/chat_divider"
android:dividerHeight="1dp"
android:listSelector="@android:color/transparent" />
</LinearLayout>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/map_mode_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/mapMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textColor="@color/secondary_text" />
<pro.nnnteam.nnnet.ui.NetworkMapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@@ -0,0 +1,246 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/screen_background"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/top_bar_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/back"
android:src="@android:drawable/ic_media_previous"
android:tint="@android:color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profile_section_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<EditText
android:id="@+id/firstNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/first_name"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/lastNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/last_name"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/usernameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/username"
android:inputType="textNoSuggestions"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<EditText
android:id="@+id/descriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:gravity="top"
android:hint="@string/profile_description"
android:minLines="3"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveProfileButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/save_profile"
app:cornerRadius="18dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/search_profile_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<EditText
android:id="@+id/searchInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:hint="@string/search_username"
android:inputType="textNoSuggestions"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textColorHint="@color/secondary_text" />
<com.google.android.material.button.MaterialButton
android:id="@+id/searchButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/find_profile"
app:cornerRadius="18dp" />
<LinearLayout
android:id="@+id/profileResultCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/bg_settings_card"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<TextView
android:id="@+id/resultNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/resultUsernameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/accent_blue"
android:textSize="14sp" />
<TextView
android:id="@+id/resultDescriptionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
<TextView
android:id="@+id/resultPeerIdText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/primary_text"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/diagnostics_title"
android:textColor="@color/primary_text"
android:textSize="18sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openMapButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/map_mode_title"
app:cornerRadius="18dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openPacketLogButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/packet_log_title"
app:cornerRadius="18dp" />
<TextView
android:id="@+id/versionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:textColor="@color/primary_text"
android:textSize="15sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoUpdateSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/bg_settings_card"
android:padding="16dp"
android:text="@string/auto_update"
android:textColor="@color/primary_text"
app:useMaterialThemeColors="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/check_updates"
app:cornerRadius="18dp" />
<TextView
android:id="@+id/updateProgressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@color/secondary_text"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="76dp"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/avatarText"
android:layout_width="52dp"
android:layout_height="52dp"
android:background="@drawable/bg_chat_avatar"
android:gravity="center"
android:textColor="@android:color/white"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/chatNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatPreviewText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="vertical">
<TextView
android:id="@+id/chatTimeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/secondary_text"
android:textSize="12sp" />
<TextView
android:id="@+id/chatStatusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="@color/accent_blue"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/messageContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<LinearLayout
android:id="@+id/messageBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginTop="2dp"
android:layout_marginBottom="2dp"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/messageBodyText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="260dp"
android:textColor="@color/primary_text"
android:textSize="16sp" />
<TextView
android:id="@+id/messageMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/message_meta"
android:textSize="11sp" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/traceTitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/primary_text"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/traceSubtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/secondary_text"
android:textSize="14sp" />
<TextView
android:id="@+id/traceMetaText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/accent_blue"
android:textSize="13sp" />
</LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_map"
android:title="@string/map_mode_title" />
<item
android:id="@+id/menu_packets"
android:title="@string/packet_log_title" />
<item
android:id="@+id/menu_settings"
android:title="@string/settings_title" />
</menu>

View File

@@ -1,5 +1,13 @@
<resources> <resources>
<color name="teal_primary">#1E6E54</color> <color name="screen_background">#F4F6F8</color>
<color name="teal_container">#A4F3D5</color> <color name="chat_background">#D9EAF4</color>
<color name="blue_secondary">#1150B4</color> <color name="top_bar_background">#527DA3</color>
<color name="top_bar_secondary_text">#D8E6F1</color>
<color name="primary_text">#1E2B37</color>
<color name="secondary_text">#72879A</color>
<color name="accent_blue">#4C9EEB</color>
<color name="fab_background">#4C9EEB</color>
<color name="chat_divider">#DDE4EA</color>
<color name="message_input_panel">#EDF2F6</color>
<color name="message_meta">#6C7E8F</color>
</resources> </resources>

View File

@@ -1,5 +1,68 @@
<resources> <resources>
<string name="app_name">NNNet</string> <string name="app_name">NNNet</string>
<string name="notification_title">NNNet</string> <string name="notification_title">NNNet</string>
<string name="notification_channel_name">Mesh status</string> <string name="notification_channel_name">Статус mesh-сети</string>
<string name="menu">Меню</string>
<string name="settings_title">Настройки</string>
<string name="chats_title">Чаты</string>
<string name="status_online">В сети</string>
<string name="status_offline">Не в сети</string>
<string name="total_devices">Устройств: %1$d</string>
<string name="no_chats">Чатов пока нет. Нажмите +, чтобы открыть новый диалог.</string>
<string name="new_chat">Новый чат</string>
<string name="new_chat_title">Новый диалог</string>
<string name="hint_peer_id">Идентификатор устройства</string>
<string name="hint_chat_target">Username или peerId</string>
<string name="open_chat">Открыть чат</string>
<string name="cancel">Отмена</string>
<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="profile_not_found_locally">Профиль не найден в локальном каталоге сети</string>
<string name="update_check_failed">Не удалось проверить обновления</string>
<string name="update_download_failed">Не удалось скачать обновление</string>
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
<string name="update_available_message">Доступна версия %1$s.</string>
<string name="download_update">Скачать обновление</string>
<string name="update_checking">Проверяем версию…</string>
<string name="update_downloading">Скачиваем APK во временный каталог…</string>
<string name="update_installing">Останавливаем сеть и запускаем установку…</string>
<string name="allow_unknown_apps">Разрешите установку из этого приложения, затем повторите обновление.</string>
<string name="installer_not_found">Системный установщик не найден</string>
<string name="later">Позже</string>
<string name="back">Назад</string>
<string name="no_messages">Сообщений пока нет. Напишите первым.</string>
<string name="message_hint">Сообщение</string>
<string name="send">Отправить</string>
<string name="message_required">Введите сообщение</string>
<string name="chat_waiting_status">Ожидание подключения mesh-сети</string>
<string name="peer_nearby">Устройство рядом</string>
<string name="message_sending">Сообщение ставится в очередь на отправку</string>
<string name="auto_update">Автоматически проверять обновления</string>
<string name="check_updates">Проверить обновления</string>
<string name="current_version">Текущая версия: %1$s (%2$d)</string>
<string name="profile_section_title">Мой профиль</string>
<string name="first_name">Имя</string>
<string name="last_name">Фамилия</string>
<string name="username">Username</string>
<string name="profile_description">Описание</string>
<string name="save_profile">Сохранить профиль</string>
<string name="search_profile_title">Найти профиль</string>
<string name="search_username">Введите username</string>
<string name="find_profile">Найти</string>
<string name="enter_username_to_search">Введите username для поиска</string>
<string name="username_required">Username обязателен</string>
<string name="profile_saved">Профиль сохранён</string>
<string name="no_profile_description">Описание не указано</string>
<string name="peer_id_unknown">peerId пока неизвестен</string>
<string name="peer_id_value">peerId: %1$s</string>
<string name="diagnostics_title">Диагностика сети</string>
<string name="map_mode_title">Режим карты</string>
<string name="packet_log_title">Журнал пакетов</string>
<string name="outgoing_packets">Исходящие</string>
<string name="incoming_packets">Входящие</string>
<string name="relay_packets">Транзит</string>
<string name="you_label">Вы</string>
<string name="map_mode_hint">Показано устройств: %1$d. Карта строится по сетевым связям и не является GPS-позицией.</string>
</resources> </resources>

View File

@@ -1,7 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<style name="Theme.SchoolMeshMessenger" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.NNNet" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">#1E6E54</item> <item name="colorPrimary">#4C9EEB</item>
<item name="colorPrimaryContainer">#A4F3D5</item> <item name="colorPrimaryContainer">#A8D6FA</item>
<item name="colorSecondary">#1150B4</item> <item name="colorSecondary">#527DA3</item>
<item name="android:statusBarColor">#527DA3</item>
</style> </style>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="update_cache"
path="updates/" />
</paths>

View File

@@ -2,11 +2,30 @@
## Слои ## Слои
- BLE Transport: сканирование, реклама, соединения, обмен пакетами. - BLE Transport: сканирование, реклама, соединения, обмен пакетами.
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK. - Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
- Messaging Layer: список чатов, диалог, статусы доставки, история. - Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
- Storage Layer: Room для локального хранения. - Storage 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`.
## Пользовательский сценарий
- Главный экран показывает список чатов в стиле Telegram.
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
- Слева в шапке показывается общее количество известных устройств в mesh.
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
- Отправка сообщений доступна только из экрана конкретного диалога.
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
- В настройках доступны режим карты сети и экран журнала пакетов.
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
## Топология сети
- Выделенный сервер или хост для работы mesh не нужен.
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
## Сетевой пакет (черновик) ## Сетевой пакет (черновик)
```json ```json
@@ -16,12 +35,12 @@
"targetId": "user-or-group-id", "targetId": "user-or-group-id",
"ttl": 6, "ttl": 6,
"timestamp": 0, "timestamp": 0,
"type": "message|ack|presence", "type": "message|ack|presence|profile",
"payload": "base64-or-json" "payload": "base64-or-json"
} }
``` ```
## Ближайшие шаги ## Ближайшие шаги
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect. 1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
2. Ввести шифрование payload и управление профилями пользователей. 2. Ввести шифрование payload и подпись пакетов.
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон. 3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.

16
scripts/create_version_tag.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_FILE="$ROOT_DIR/android/app/build.gradle.kts"
version_name="$(grep -E 'versionName = "[0-9]+\.[0-9]+\.[0-9]+"' "$BUILD_FILE" | head -n1 | sed -E 's/.*versionName = "([0-9]+\.[0-9]+\.[0-9]+)"/\1/')"
tag_name="v${version_name}"
if git -C "$ROOT_DIR" rev-parse -q --verify "refs/tags/${tag_name}" >/dev/null; then
echo "Tag ${tag_name} already exists"
exit 0
fi
git -C "$ROOT_DIR" tag -a "$tag_name" -m "Release ${tag_name}"
echo "Created tag ${tag_name}"

23
scripts/update_release_notes.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_FILE="$ROOT_DIR/website/assets/meta/release-notes.txt"
release_notes="${RELEASE_NOTES:-}"
release_notes_file="${RELEASE_NOTES_FILE:-}"
if [[ -n "$release_notes_file" ]]; then
if [[ ! -f "$release_notes_file" ]]; then
echo "RELEASE_NOTES_FILE does not exist: $release_notes_file" >&2
exit 1
fi
cp "$release_notes_file" "$TARGET_FILE"
elif [[ -n "$release_notes" ]]; then
printf '%s\n' "$release_notes" > "$TARGET_FILE"
else
echo "Release notes are required. Use RELEASE_NOTES='- item 1\n- item 2' or RELEASE_NOTES_FILE=/path/to/file." >&2
exit 1
fi
echo "Release notes updated"

View File

@@ -60,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
releaseNotesText.innerHTML = notes.trim().replace(/\n/g, '<br>'); releaseNotesText.innerHTML = notes.trim().replace(/\n/g, '<br>');
}) })
.catch(() => { .catch(() => {
releaseNotesText.textContent = 'Не удалось загрузить changelog.'; releaseNotesText.textContent = 'Не удалось загрузить описание обновления.';
}); });
} }
}) })

View File

@@ -1,5 +1,3 @@
- Добавлено хранение сообщений и очереди отправки через Room. - Добавлено меню: Карта сети, Пакеты и Настройки.
- Добавлен цикл доставки с ACK, retry и фоновой обработкой в mesh service. - Добавлен встроенный поток обновления: проверка версии, скачивание APK во временный каталог, остановка mesh и запуск системной установки.
- Добавлен интерфейс в стиле мессенджера: список чатов, окно диалога и настройки. - Улучшена навигация по диагностическим инструментам сети.
- Добавлен запрос на включение Bluetooth, если он выключен.
- Добавлена ручная проверка обновлений и опциональная автопроверка в настройках.

View File

@@ -40,9 +40,9 @@
<div class="container"> <div class="container">
<h2 class="mb-4">Что внутри</h2> <h2 class="mb-4">Что внутри</h2>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE Discovery</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 Relay</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и retry queue.</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">Local Storage</h5><p class="mb-0">Room хранит историю сообщений и очередь исходящей доставки.</p></div></div> <div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений, очередь доставки, кэш профилей и журнал пакетов.</p></div></div>
</div> </div>
</div> </div>
</section> </section>
@@ -65,6 +65,6 @@
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/app.js?v=3"></script> <script src="assets/js/app.js?v=4"></script>
</body> </body>
</html> </html>