Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da681cbd23 | ||
|
|
c158fd63b6 | ||
|
|
909d1462f7 | ||
|
|
88388ec115 | ||
|
|
b4df94200e | ||
|
|
1cfdb42e04 | ||
|
|
3f304e901c | ||
|
|
9d37001e2b | ||
|
|
f57f4716a5 |
22
Makefile
22
Makefile
@@ -9,32 +9,44 @@ APK_PATH := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
|
||||
PUBLISHED_APK := $(DOWNLOADS_DIR)/app-debug.apk
|
||||
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
|
||||
|
||||
help:
|
||||
@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 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-stop - stop nginx"
|
||||
@echo " make server-restart - restart nginx"
|
||||
@echo " make server-status - show nginx status"
|
||||
@echo " make server-rebuild - test nginx config and reload nginx"
|
||||
|
||||
require-release-notes:
|
||||
@$(PROJECT_ROOT)/scripts/update_release_notes.sh
|
||||
|
||||
client-version-bump:
|
||||
@$(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
|
||||
@$(PROJECT_ROOT)/scripts/create_version_tag.sh
|
||||
|
||||
client-publish:
|
||||
@mkdir -p $(DOWNLOADS_DIR) $(META_DIR)
|
||||
cp $(APK_PATH) $(PUBLISHED_APK)
|
||||
@$(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:
|
||||
sudo systemctl start nginx
|
||||
|
||||
35
README.md
35
README.md
@@ -11,10 +11,16 @@
|
||||
## Текущее состояние
|
||||
- BLE discovery + advertising работают.
|
||||
- Реализован минимальный GATT transport для обмена mesh-пакетами.
|
||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и базовый Telegram-подобный UI.
|
||||
- Реализованы список чатов, окно диалога, вкладка настроек, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
||||
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`.
|
||||
- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`.
|
||||
- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`.
|
||||
- В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов.
|
||||
- Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`.
|
||||
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
||||
- Публикация APK и сайта автоматизирована через `Makefile`.
|
||||
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
|
||||
|
||||
## Стек
|
||||
- Android приложение: **Kotlin**
|
||||
@@ -42,12 +48,12 @@
|
||||
|
||||
3. **Messaging Layer**
|
||||
- личные сообщения;
|
||||
- список чатов и окно диалога;
|
||||
- список чатов и отдельный экран диалога;
|
||||
- статусы доставки (queued/sent/relayed/delivered).
|
||||
|
||||
4. **Data Layer**
|
||||
- локальное хранилище (Room);
|
||||
- история сообщений и очередь исходящей доставки.
|
||||
- история сообщений, очередь исходящей доставки, каталог профилей и журнал пакетов.
|
||||
|
||||
5. **Security Layer**
|
||||
- идентификация пользователя;
|
||||
@@ -78,10 +84,14 @@
|
||||
- [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово).
|
||||
- [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово).
|
||||
- [x] Добавить список чатов и базовый UI окна сообщений.
|
||||
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
|
||||
- [x] Подключить Room и базовую схему хранения.
|
||||
- [x] Реализовать базовую регистрацию пользователя (локальный профиль).
|
||||
- [x] Добавить кэш профилей из mesh-сети и поиск по `username`.
|
||||
- [x] Добавить журнал исходящих, входящих и транзитных пакетов.
|
||||
- [x] Добавить режим карты сети в настройках.
|
||||
- [x] Добавить логирование сети и debug-экран маршрутов.
|
||||
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
||||
- [ ] Реализовать базовую регистрацию пользователя (локальный профиль).
|
||||
- [ ] Добавить шифрование полезной нагрузки сообщений.
|
||||
- [ ] Написать инструментальные тесты BLE-обмена.
|
||||
- [x] Создать сайт (`index.html`, `styles.css`, `app.js`) на Bootstrap.
|
||||
@@ -95,13 +105,22 @@
|
||||
- `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 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`.
|
||||
|
||||
## Лицензия
|
||||
Проект использует лицензию `GPL-3.0`. См. [LICENSE](/home/dom4k/nnnet/LICENSE).
|
||||
|
||||
## Ближайший следующий шаг
|
||||
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
||||
Добавить шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
||||
|
||||
## Ограничения сети
|
||||
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
|
||||
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
||||
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
||||
- Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh.
|
||||
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||
|
||||
@@ -5,15 +5,15 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.schoolmesh.messenger"
|
||||
namespace = "pro.nnnteam.nnnet"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.schoolmesh.messenger"
|
||||
applicationId = "pro.nnnteam.nnnet"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "0.1.1"
|
||||
versionCode = 5
|
||||
versionName = "0.1.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -24,7 +25,20 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -40,6 +54,16 @@
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
|
||||
enum class PacketType {
|
||||
MESSAGE,
|
||||
ACK,
|
||||
PRESENCE
|
||||
}
|
||||
225
android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt
Normal file
225
android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
407
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal file
407
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
281
android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt
Normal file
281
android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
data class ChatSummary(
|
||||
val peerId: String,
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
@@ -6,13 +6,15 @@ import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [MessageEntity::class, OutboundQueueEntity::class],
|
||||
version = 1,
|
||||
entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class MeshDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun outboundQueueDao(): OutboundQueueDao
|
||||
abstract fun profileDao(): ProfileDao
|
||||
abstract fun packetTraceDao(): PacketTraceDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
@@ -24,7 +26,10 @@ abstract class MeshDatabase : RoomDatabase() {
|
||||
context.applicationContext,
|
||||
MeshDatabase::class.java,
|
||||
"mesh.db"
|
||||
).build().also { instance = it }
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.data
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
@@ -0,0 +1,41 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface PacketTraceDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(trace: PacketTraceEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM packet_traces
|
||||
WHERE traceDirection = :direction
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recentByDirection(direction: String, limit: Int): List<PacketTraceEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM packet_traces
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recent(limit: Int): List<PacketTraceEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT relatedPeerId FROM packet_traces
|
||||
WHERE relatedPeerId != ''
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun recentPeerIds(limit: Int): List<String>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "packet_traces")
|
||||
data class PacketTraceEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val traceDirection: String,
|
||||
val packetType: String,
|
||||
val messageId: String,
|
||||
val senderId: String,
|
||||
val targetId: String,
|
||||
val relatedPeerId: String,
|
||||
val payloadPreview: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package pro.nnnteam.nnnet.data
|
||||
|
||||
data class PacketTraceSummary(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val meta: String
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -7,11 +7,12 @@ import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothGattServerCallback
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
@@ -21,7 +22,6 @@ import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
@@ -38,6 +38,10 @@ class BleMeshManager(
|
||||
private val onStatusChanged: (String) -> Unit = {},
|
||||
private val onAckReceived: (String) -> Unit = {},
|
||||
private val onMessageReceived: (MeshPacket) -> Unit = {},
|
||||
private val onProfileReceived: (MeshPacket) -> Unit = {},
|
||||
private val onPacketOutgoing: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onPacketIncoming: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onPacketRelay: (MeshPacket, String) -> Unit = { _, _ -> },
|
||||
private val onError: (String) -> Unit = {},
|
||||
private val onLog: (String) -> Unit = {},
|
||||
private val seenPacketCache: SeenPacketCache = SeenPacketCache()
|
||||
@@ -69,22 +73,22 @@ class BleMeshManager(
|
||||
if (address == localNodeId || activeConnections.containsKey(address)) {
|
||||
return
|
||||
}
|
||||
log("Discovered BLE node: $address")
|
||||
log("Обнаружен BLE-узел: $address")
|
||||
connectToPeer(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
fail("BLE scan failed: $errorCode")
|
||||
fail("Ошибка BLE-сканирования: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
private val advertiseCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
log("BLE advertising started")
|
||||
log("Запущен BLE advertising")
|
||||
}
|
||||
|
||||
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)
|
||||
log("Packet received from ${device.address}: $rawPacket")
|
||||
log("Пакет получен от ${device.address}: $rawPacket")
|
||||
handleIncomingPacket(rawPacket)
|
||||
|
||||
if (responseNeeded) {
|
||||
@@ -125,20 +129,20 @@ class BleMeshManager(
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
val address = device.address ?: return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
log("GATT client error for $address: status=$status")
|
||||
log("Ошибка GATT-клиента для $address: status=$status")
|
||||
closeConnection(address)
|
||||
return
|
||||
}
|
||||
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
log("Connected to peer $address")
|
||||
log("Подключено к узлу $address")
|
||||
activeConnections[address] = gatt
|
||||
gatt.discoverServices()
|
||||
}
|
||||
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
log("Disconnected from peer $address")
|
||||
log("Узел отключился: $address")
|
||||
closeConnection(address)
|
||||
}
|
||||
}
|
||||
@@ -146,11 +150,12 @@ class BleMeshManager(
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
log("Service discovery failed for ${device.address}: $status")
|
||||
log("Не удалось обнаружить сервисы у ${device.address}: $status")
|
||||
return
|
||||
}
|
||||
log("Services discovered for ${device.address}")
|
||||
log("Сервисы обнаружены у ${device.address}")
|
||||
sendPresence(gatt)
|
||||
device.address?.let(onPeerDiscovered)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
@@ -160,9 +165,9 @@ class BleMeshManager(
|
||||
) {
|
||||
val address = device.address ?: return
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
log("Packet sent to $address")
|
||||
log("Пакет отправлен на $address")
|
||||
} else {
|
||||
log("Packet send failed to $address: status=$status")
|
||||
log("Ошибка отправки пакета на $address: status=$status")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,22 +183,37 @@ class BleMeshManager(
|
||||
}
|
||||
|
||||
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.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() {
|
||||
if (isRunning) return
|
||||
if (!hasRequiredRuntimePermissions()) {
|
||||
fail("BLE permissions are missing")
|
||||
fail("Не выданы BLE-разрешения")
|
||||
return
|
||||
}
|
||||
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
fail("Bluetooth adapter is unavailable or disabled")
|
||||
fail("Bluetooth недоступен или выключен")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -201,7 +221,7 @@ class BleMeshManager(
|
||||
startScanning()
|
||||
startAdvertising()
|
||||
isRunning = true
|
||||
onStatusChanged("Mesh активен, идет discovery и GATT transport")
|
||||
onStatusChanged("NNNet в сети, поиск соседей и транспорт GATT активны")
|
||||
log("BLE mesh manager started with nodeId=$localNodeId")
|
||||
}
|
||||
|
||||
@@ -219,50 +239,66 @@ class BleMeshManager(
|
||||
inboundCharacteristic = null
|
||||
gattServer = null
|
||||
isRunning = false
|
||||
onStatusChanged("Mesh остановлен")
|
||||
onStatusChanged("NNNet оффлайн")
|
||||
log("BLE mesh manager stopped")
|
||||
}
|
||||
|
||||
private fun handleIncomingPacket(rawPacket: String) {
|
||||
val packet = runCatching { MeshPacketCodec.decode(rawPacket) }
|
||||
.getOrElse {
|
||||
fail("Packet decode failed: ${it.message}")
|
||||
fail("Не удалось декодировать пакет: ${it.message}")
|
||||
return
|
||||
}
|
||||
|
||||
when (val action = onPacketReceived(packet)) {
|
||||
MeshAction.DropDuplicate -> log("Duplicate packet dropped: ${packet.messageId}")
|
||||
MeshAction.DropExpired -> log("Expired packet dropped: ${packet.messageId}")
|
||||
MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}")
|
||||
MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}")
|
||||
is MeshAction.ConsumeAck -> {
|
||||
onPacketIncoming(packet, packet.senderId)
|
||||
onAckReceived(action.messageId)
|
||||
log("ACK consumed: ${action.messageId}")
|
||||
log("ACK обработан: ${action.messageId}")
|
||||
}
|
||||
is MeshAction.ConsumePresence -> {
|
||||
onPacketIncoming(packet, packet.senderId)
|
||||
onPeerDiscovered(action.senderId)
|
||||
onStatusChanged("Presence from ${action.senderId}")
|
||||
log("Presence consumed from ${action.senderId}")
|
||||
onStatusChanged("Устройство ${action.senderId} рядом")
|
||||
log("Сигнал присутствия обработан от ${action.senderId}")
|
||||
}
|
||||
is MeshAction.DeliverMessage -> {
|
||||
onPacketIncoming(action.packet, action.packet.senderId)
|
||||
onMessageReceived(action.packet)
|
||||
onStatusChanged("Новое сообщение от ${action.packet.senderId}")
|
||||
sendAck(action.packet)
|
||||
}
|
||||
is MeshAction.CacheProfile -> {
|
||||
onPacketRelay(action.packet, action.packet.senderId)
|
||||
onProfileReceived(action.packet)
|
||||
broadcastIfAlive(action.packetToRelay)
|
||||
}
|
||||
is MeshAction.Relay -> {
|
||||
onPacketRelay(action.packetToRelay, action.packetToRelay.senderId)
|
||||
log("Ретрансляция пакета ${action.packetToRelay.messageId}")
|
||||
broadcastIfAlive(action.packetToRelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MeshAction.ProcessAndRelay -> {
|
||||
onMessageReceived(packet)
|
||||
onStatusChanged("Message from ${packet.senderId}")
|
||||
log("Relaying packet ${packet.messageId}")
|
||||
broadcastPacket(action.packetToRelay)
|
||||
sendAck(packet)
|
||||
}
|
||||
private fun broadcastIfAlive(packet: MeshPacket) {
|
||||
if (!packet.isExpired()) {
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startGattServer() {
|
||||
val manager = bluetoothManager ?: run {
|
||||
fail("BluetoothManager unavailable")
|
||||
fail("BluetoothManager недоступен")
|
||||
return
|
||||
}
|
||||
|
||||
val server = manager.openGattServer(context, gattServerCallback)
|
||||
if (server == null) {
|
||||
fail("Failed to open GATT server")
|
||||
fail("Не удалось открыть GATT server")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,7 +354,7 @@ class BleMeshManager(
|
||||
private fun connectToPeer(device: BluetoothDevice) {
|
||||
val address = device.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) {
|
||||
device.connectGatt(context, false, MeshGattCallback(device), BluetoothDevice.TRANSPORT_LE)
|
||||
} else {
|
||||
@@ -333,10 +369,11 @@ class BleMeshManager(
|
||||
private fun sendPresence(gatt: BluetoothGatt) {
|
||||
val packet = MeshPacket(
|
||||
senderId = localNodeId,
|
||||
targetId = gatt.device.address ?: "broadcast",
|
||||
targetId = gatt.device.address ?: BROADCAST_TARGET,
|
||||
type = PacketType.PRESENCE,
|
||||
payload = "presence:$localNodeId"
|
||||
)
|
||||
onPacketOutgoing(packet, gatt.device.address ?: "")
|
||||
writePacket(gatt, packet)
|
||||
}
|
||||
|
||||
@@ -347,6 +384,7 @@ class BleMeshManager(
|
||||
type = PacketType.ACK,
|
||||
payload = packet.messageId
|
||||
)
|
||||
onPacketOutgoing(ack, packet.senderId)
|
||||
broadcastPacket(ack)
|
||||
}
|
||||
|
||||
@@ -359,6 +397,7 @@ class BleMeshManager(
|
||||
}
|
||||
|
||||
fun sendPacket(packet: MeshPacket): Boolean {
|
||||
onPacketOutgoing(packet, packet.targetId)
|
||||
val directedGatt = activeConnections[packet.targetId]
|
||||
return if (directedGatt != null) {
|
||||
writePacket(directedGatt, packet)
|
||||
@@ -374,7 +413,7 @@ class BleMeshManager(
|
||||
?.getCharacteristic(CHARACTERISTIC_PACKET_UUID)
|
||||
|
||||
if (characteristic == null) {
|
||||
log("Remote characteristic missing on ${gatt.device.address}")
|
||||
log("У удалённого узла нет mesh-характеристики: ${gatt.device.address}")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -435,6 +474,7 @@ class BleMeshManager(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BROADCAST_TARGET = "*"
|
||||
private const val TAG = "BleMeshManager"
|
||||
private val MESH_SERVICE_UUID: UUID = UUID.fromString("8fa8f9f0-e755-4c1d-9ac2-4f0a02e07f8b")
|
||||
private val CHARACTERISTIC_PACKET_UUID: UUID =
|
||||
@@ -447,5 +487,7 @@ sealed interface MeshAction {
|
||||
data object DropExpired : MeshAction
|
||||
data class ConsumeAck(val messageId: 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -9,32 +9,43 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
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() {
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var bleMeshManager: BleMeshManager
|
||||
private lateinit var repository: MeshRepository
|
||||
private lateinit var queueProcessor: MeshQueueProcessor
|
||||
private var lastProfileBroadcastAt = 0L
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
val database = MeshDatabase.getInstance(applicationContext)
|
||||
repository = MeshRepository(database.messageDao(), database.outboundQueueDao())
|
||||
repository = MeshRepository(
|
||||
database.messageDao(),
|
||||
database.outboundQueueDao(),
|
||||
database.profileDao(),
|
||||
database.packetTraceDao()
|
||||
)
|
||||
bleMeshManager = BleMeshManager(
|
||||
context = applicationContext,
|
||||
onPeerDiscovered = { address ->
|
||||
sendEvent(MeshServiceContract.EVENT_PEER, address)
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
|
||||
queueProcessor.poke()
|
||||
serviceScope.launch {
|
||||
publishLocalProfile(force = false)
|
||||
}
|
||||
},
|
||||
onStatusChanged = { status ->
|
||||
sendEvent(MeshServiceContract.EVENT_STATUS, status)
|
||||
@@ -43,21 +54,46 @@ class MeshForegroundService : Service() {
|
||||
onAckReceived = { messageId ->
|
||||
serviceScope.launch {
|
||||
repository.markAckDelivered(messageId)
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "ACK delivered for $messageId")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "ACK получен для $messageId")
|
||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
||||
}
|
||||
},
|
||||
onMessageReceived = { packet ->
|
||||
serviceScope.launch {
|
||||
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)
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message")
|
||||
updateNotification("Ошибка mesh")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
|
||||
updateNotification("Ошибка сети")
|
||||
},
|
||||
onLog = { message ->
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, message)
|
||||
@@ -90,10 +126,45 @@ class MeshForegroundService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun startMesh() {
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается"))
|
||||
startForeground(NOTIFICATION_ID, buildNotification("NNNet запускает сеть"))
|
||||
bleMeshManager.start()
|
||||
queueProcessor.start()
|
||||
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() {
|
||||
@@ -107,7 +178,7 @@ class MeshForegroundService : Service() {
|
||||
val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty()
|
||||
val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty()
|
||||
if (targetId.isEmpty() || messageBody.isEmpty()) {
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Cannot enqueue empty target/body")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Нельзя поставить в очередь пустое сообщение")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +188,7 @@ class MeshForegroundService : Service() {
|
||||
targetId = targetId,
|
||||
body = messageBody
|
||||
)
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Message queued: $messageId")
|
||||
sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение поставлено в очередь: $messageId")
|
||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
||||
queueProcessor.poke()
|
||||
}
|
||||
@@ -160,6 +231,7 @@ class MeshForegroundService : Service() {
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "mesh_status"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val PROFILE_BROADCAST_THROTTLE_MS = 5_000L
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, MeshForegroundService::class.java).apply {
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -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.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
object MeshServiceContract {
|
||||
const val ACTION_START = "com.schoolmesh.messenger.mesh.START"
|
||||
const val ACTION_STOP = "com.schoolmesh.messenger.mesh.STOP"
|
||||
const val ACTION_SEND_MESSAGE = "com.schoolmesh.messenger.mesh.SEND_MESSAGE"
|
||||
const val ACTION_EVENT = "com.schoolmesh.messenger.mesh.EVENT"
|
||||
const val ACTION_START = "pro.nnnteam.nnnet.mesh.START"
|
||||
const val ACTION_STOP = "pro.nnnteam.nnnet.mesh.STOP"
|
||||
const val ACTION_SEND_MESSAGE = "pro.nnnteam.nnnet.mesh.SEND_MESSAGE"
|
||||
const val ACTION_EVENT = "pro.nnnteam.nnnet.mesh.EVENT"
|
||||
|
||||
const val EXTRA_EVENT_TYPE = "event_type"
|
||||
const val EXTRA_EVENT_VALUE = "event_value"
|
||||
@@ -15,4 +15,5 @@ object MeshServiceContract {
|
||||
const val EVENT_PEER = "peer"
|
||||
const val EVENT_LOG = "log"
|
||||
const val EVENT_MESSAGES_CHANGED = "messages_changed"
|
||||
const val EVENT_PROFILES_CHANGED = "profiles_changed"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
enum class PacketType {
|
||||
MESSAGE,
|
||||
ACK,
|
||||
PRESENCE,
|
||||
PROFILE
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.schoolmesh.messenger.mesh
|
||||
package pro.nnnteam.nnnet.mesh
|
||||
|
||||
class SeenPacketCache(
|
||||
private val maxSize: Int = 512
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package pro.nnnteam.nnnet.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
class NetworkMapView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#4C9EEB")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val selfPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#33A56E")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#1E2B37")
|
||||
textSize = 34f
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#A7B8C7")
|
||||
strokeWidth = 4f
|
||||
}
|
||||
|
||||
private var nodes: List<MapNodeUi> = emptyList()
|
||||
|
||||
fun submitNodes(items: List<MapNodeUi>) {
|
||||
nodes = items
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val radius = min(width, height) * 0.32f
|
||||
val selfNode = nodes.first()
|
||||
|
||||
canvas.drawCircle(cx, cy, 52f, selfPaint)
|
||||
canvas.drawText(selfNode.label, cx, cy + 86f, textPaint)
|
||||
|
||||
val others = nodes.drop(1)
|
||||
if (others.isEmpty()) return
|
||||
|
||||
others.forEachIndexed { index, node ->
|
||||
val angle = (2 * PI * index / others.size) - PI / 2
|
||||
val nx = cx + (radius * cos(angle)).toFloat()
|
||||
val ny = cy + (radius * sin(angle)).toFloat()
|
||||
canvas.drawLine(cx, cy, nx, ny, linePaint)
|
||||
canvas.drawCircle(nx, ny, 42f, nodePaint)
|
||||
canvas.drawText(node.label, nx, ny + 76f, textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MapNodeUi(
|
||||
val label: String,
|
||||
val peerId: String,
|
||||
val isSelf: Boolean
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package pro.nnnteam.nnnet.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import pro.nnnteam.nnnet.R
|
||||
import pro.nnnteam.nnnet.data.PacketTraceSummary
|
||||
|
||||
class PacketTraceAdapter(
|
||||
context: Context,
|
||||
private val items: MutableList<PacketTraceSummary>
|
||||
) : BaseAdapter() {
|
||||
private val inflater = LayoutInflater.from(context)
|
||||
|
||||
override fun getCount(): Int = items.size
|
||||
|
||||
override fun getItem(position: Int): PacketTraceSummary = items[position]
|
||||
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: inflater.inflate(R.layout.item_packet_trace, parent, false)
|
||||
val item = getItem(position)
|
||||
view.findViewById<TextView>(R.id.traceTitleText).text = item.title
|
||||
view.findViewById<TextView>(R.id.traceSubtitleText).text = item.subtitle
|
||||
view.findViewById<TextView>(R.id.traceMetaText).text = item.meta
|
||||
return view
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pro.nnnteam.nnnet.update
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import pro.nnnteam.nnnet.R
|
||||
import pro.nnnteam.nnnet.mesh.MeshForegroundService
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object UpdateInstaller {
|
||||
suspend fun downloadToTempFile(context: Context, updateInfo: UpdateInfo): File? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val updatesDir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val apkFile = File(updatesDir, "nnnet-update-${updateInfo.versionName}.apk")
|
||||
val connection = URL(UpdateManager.buildDownloadUrl(updateInfo.apkPath)).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.inputStream.use { input ->
|
||||
apkFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
apkFile
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun installDownloadedApk(context: Context, apkFile: File, stopMesh: Boolean = true): Boolean {
|
||||
if (!apkFile.exists()) return false
|
||||
if (stopMesh) {
|
||||
MeshForegroundService.stop(context)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
|
||||
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Toast.makeText(context, R.string.allow_unknown_apps, Toast.LENGTH_LONG).show()
|
||||
return false
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
return try {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, R.string.installer_not_found, Toast.LENGTH_SHORT).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
4
android/app/src/main/res/drawable/bg_chat_avatar.xml
Normal file
4
android/app/src/main/res/drawable/bg_chat_avatar.xml
Normal 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>
|
||||
@@ -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>
|
||||
5
android/app/src/main/res/drawable/bg_message_input.xml
Normal file
5
android/app/src/main/res/drawable/bg_message_input.xml
Normal 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>
|
||||
@@ -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>
|
||||
4
android/app/src/main/res/drawable/bg_send_button.xml
Normal file
4
android/app/src/main/res/drawable/bg_send_button.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/bg_settings_card.xml
Normal file
5
android/app/src/main/res/drawable/bg_settings_card.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/bg_status_offline.xml
Normal file
5
android/app/src/main/res/drawable/bg_status_offline.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/bg_status_online.xml
Normal file
5
android/app/src/main/res/drawable/bg_status_online.xml
Normal 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>
|
||||
120
android/app/src/main/res/layout/activity_chat.xml
Normal file
120
android/app/src/main/res/layout/activity_chat.xml
Normal 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>
|
||||
@@ -1,242 +1,124 @@
|
||||
<?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:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#18222D"
|
||||
android:orientation="vertical"
|
||||
tools:context=".MainActivity">
|
||||
android:background="@color/screen_background">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
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:layout_height="match_parent"
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#121A23"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:text="Chats"
|
||||
android:textColor="#8FA1B3"
|
||||
android:textStyle="bold" />
|
||||
android:background="@color/top_bar_background"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="@string/chats_title"
|
||||
android:textColor="@color/top_bar_secondary_text"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/chatListView"
|
||||
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_weight="1"
|
||||
android:background="#0E1621"
|
||||
android:divider="@android:color/transparent"
|
||||
android:dividerHeight="8dp"
|
||||
android:padding="12dp"
|
||||
android:transcriptMode="alwaysScroll" />
|
||||
android:background="@color/screen_background"
|
||||
android:divider="@color/chat_divider"
|
||||
android:dividerHeight="1dp"
|
||||
android:listSelector="@android:color/transparent"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="92dp"
|
||||
android:scrollbars="none" />
|
||||
|
||||
<LinearLayout
|
||||
<TextView
|
||||
android:id="@+id/emptyStateText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#18222D"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/targetInput"
|
||||
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>
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:text="@string/no_chats"
|
||||
android:textColor="@color/secondary_text"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/settingsScreen"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:visibility="gone">
|
||||
|
||||
<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="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>
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/newChatButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="20dp"
|
||||
android:contentDescription="@string/new_chat"
|
||||
android:src="@android:drawable/ic_input_add"
|
||||
app:backgroundTint="@color/fab_background"
|
||||
app:tint="@android:color/white" />
|
||||
</FrameLayout>
|
||||
|
||||
75
android/app/src/main/res/layout/activity_packet_log.xml
Normal file
75
android/app/src/main/res/layout/activity_packet_log.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/screen_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/top_bar_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/back"
|
||||
android:src="@android:drawable/ic_media_previous"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/packet_log_title"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/outgoingButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/outgoing_packets" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/incomingButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/incoming_packets" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/relayButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/relay_packets" />
|
||||
</LinearLayout>
|
||||
|
||||
<ListView
|
||||
android:id="@+id/packetListView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:divider="@color/chat_divider"
|
||||
android:dividerHeight="1dp"
|
||||
android:listSelector="@android:color/transparent" />
|
||||
</LinearLayout>
|
||||
50
android/app/src/main/res/layout/activity_packet_map.xml
Normal file
50
android/app/src/main/res/layout/activity_packet_map.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/screen_background"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/top_bar_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/back"
|
||||
android:src="@android:drawable/ic_media_previous"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/map_mode_title"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapMetaText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:textColor="@color/secondary_text" />
|
||||
|
||||
<pro.nnnteam.nnnet.ui.NetworkMapView
|
||||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
246
android/app/src/main/res/layout/activity_settings.xml
Normal file
246
android/app/src/main/res/layout/activity_settings.xml
Normal 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>
|
||||
70
android/app/src/main/res/layout/item_chat_summary.xml
Normal file
70
android/app/src/main/res/layout/item_chat_summary.xml
Normal 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>
|
||||
38
android/app/src/main/res/layout/item_message.xml
Normal file
38
android/app/src/main/res/layout/item_message.xml
Normal 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>
|
||||
31
android/app/src/main/res/layout/item_packet_trace.xml
Normal file
31
android/app/src/main/res/layout/item_packet_trace.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceTitleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/primary_text"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceSubtitleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/secondary_text"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/traceMetaText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/accent_blue"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
12
android/app/src/main/res/menu/main_menu.xml
Normal file
12
android/app/src/main/res/menu/main_menu.xml
Normal 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>
|
||||
@@ -1,5 +1,13 @@
|
||||
<resources>
|
||||
<color name="teal_primary">#1E6E54</color>
|
||||
<color name="teal_container">#A4F3D5</color>
|
||||
<color name="blue_secondary">#1150B4</color>
|
||||
<color name="screen_background">#F4F6F8</color>
|
||||
<color name="chat_background">#D9EAF4</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>
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
<resources>
|
||||
<string name="app_name">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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.SchoolMeshMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">#1E6E54</item>
|
||||
<item name="colorPrimaryContainer">#A4F3D5</item>
|
||||
<item name="colorSecondary">#1150B4</item>
|
||||
<resources>
|
||||
<style name="Theme.NNNet" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">#4C9EEB</item>
|
||||
<item name="colorPrimaryContainer">#A8D6FA</item>
|
||||
<item name="colorSecondary">#527DA3</item>
|
||||
<item name="android:statusBarColor">#527DA3</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="update_cache"
|
||||
path="updates/" />
|
||||
</paths>
|
||||
@@ -2,11 +2,30 @@
|
||||
|
||||
## Слои
|
||||
- BLE Transport: сканирование, реклама, соединения, обмен пакетами.
|
||||
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK.
|
||||
- Messaging Layer: список чатов, диалог, статусы доставки, история.
|
||||
- Storage Layer: Room для локального хранения.
|
||||
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK, ретрансляция профильных пакетов.
|
||||
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
|
||||
- Storage Layer: Room для локального хранения сообщений, очереди и профилей.
|
||||
- Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`.
|
||||
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
||||
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
||||
- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`.
|
||||
|
||||
## Пользовательский сценарий
|
||||
- Главный экран показывает список чатов в стиле Telegram.
|
||||
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
|
||||
- Слева в шапке показывается общее количество известных устройств в mesh.
|
||||
- В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||
- Отправка сообщений доступна только из экрана конкретного диалога.
|
||||
- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`.
|
||||
- В настройках доступны режим карты сети и экран журнала пакетов.
|
||||
- Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`.
|
||||
|
||||
## Топология сети
|
||||
- Выделенный сервер или хост для работы mesh не нужен.
|
||||
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
||||
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
||||
- Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети.
|
||||
- Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется.
|
||||
|
||||
## Сетевой пакет (черновик)
|
||||
```json
|
||||
@@ -16,12 +35,12 @@
|
||||
"targetId": "user-or-group-id",
|
||||
"ttl": 6,
|
||||
"timestamp": 0,
|
||||
"type": "message|ack|presence",
|
||||
"type": "message|ack|presence|profile",
|
||||
"payload": "base64-or-json"
|
||||
}
|
||||
```
|
||||
|
||||
## Ближайшие шаги
|
||||
1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect.
|
||||
2. Ввести шифрование payload и управление профилями пользователей.
|
||||
2. Ввести шифрование payload и подпись пакетов.
|
||||
3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон.
|
||||
|
||||
16
scripts/create_version_tag.sh
Executable file
16
scripts/create_version_tag.sh
Executable 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
23
scripts/update_release_notes.sh
Executable 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"
|
||||
@@ -60,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
releaseNotesText.innerHTML = notes.trim().replace(/\n/g, '<br>');
|
||||
})
|
||||
.catch(() => {
|
||||
releaseNotesText.textContent = 'Не удалось загрузить changelog.';
|
||||
releaseNotesText.textContent = 'Не удалось загрузить описание обновления.';
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
- Добавлено хранение сообщений и очереди отправки через Room.
|
||||
- Добавлен цикл доставки с ACK, retry и фоновой обработкой в mesh service.
|
||||
- Добавлен интерфейс в стиле мессенджера: список чатов, окно диалога и настройки.
|
||||
- Добавлен запрос на включение Bluetooth, если он выключен.
|
||||
- Добавлена ручная проверка обновлений и опциональная автопроверка в настройках.
|
||||
- Добавлено меню: Карта сети, Пакеты и Настройки.
|
||||
- Добавлен встроенный поток обновления: проверка версии, скачивание APK во временный каталог, остановка mesh и запуск системной установки.
|
||||
- Улучшена навигация по диагностическим инструментам сети.
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
<div class="container">
|
||||
<h2 class="mb-4">Что внутри</h2>
|
||||
<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-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-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-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div>
|
||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div>
|
||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений, очередь доставки, кэш профилей и журнал пакетов.</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -65,6 +65,6 @@
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user