Refine NNNet UI and rename Android package
Some checks failed
Android CI / build (push) Has been cancelled
Some checks failed
Android CI / build (push) Has been cancelled
This commit is contained in:
14
README.md
14
README.md
@@ -11,10 +11,11 @@
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
- BLE discovery + advertising работают.
|
- BLE discovery + advertising работают.
|
||||||
- Реализован минимальный GATT transport для обмена mesh-пакетами.
|
- Реализован минимальный GATT transport для обмена mesh-пакетами.
|
||||||
- Есть foreground service, Room-хранилище, ACK/retry очередь и базовый Telegram-подобный UI.
|
- Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram.
|
||||||
- Реализованы список чатов, окно диалога, вкладка настроек, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
- Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`.
|
||||||
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
- При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh.
|
||||||
- Публикация APK и сайта автоматизирована через `Makefile`.
|
- Публикация APK и сайта автоматизирована через `Makefile`.
|
||||||
|
- Проект и Android-приложение приведены к имени `NNNet`, пакет приложения: `pro.nnnteam.nnnet`.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
- Android приложение: **Kotlin**
|
- Android приложение: **Kotlin**
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
|
|
||||||
3. **Messaging Layer**
|
3. **Messaging Layer**
|
||||||
- личные сообщения;
|
- личные сообщения;
|
||||||
- список чатов и окно диалога;
|
- список чатов и отдельный экран диалога;
|
||||||
- статусы доставки (queued/sent/relayed/delivered).
|
- статусы доставки (queued/sent/relayed/delivered).
|
||||||
|
|
||||||
4. **Data Layer**
|
4. **Data Layer**
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
- [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово).
|
- [x] Добавить защиту от дубликатов по `messageId` (in-memory cache, базово).
|
||||||
- [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово).
|
- [x] Реализовать mesh-forwarding с ограничением TTL (routing action layer, базово).
|
||||||
- [x] Добавить список чатов и базовый UI окна сообщений.
|
- [x] Добавить список чатов и базовый UI окна сообщений.
|
||||||
|
- [x] Перенести настройки в меню `три точки` и убрать debug-лог из пользовательского интерфейса.
|
||||||
- [x] Подключить Room и базовую схему хранения.
|
- [x] Подключить Room и базовую схему хранения.
|
||||||
- [x] Добавить логирование сети и debug-экран маршрутов.
|
- [x] Добавить логирование сети и debug-экран маршрутов.
|
||||||
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
- [x] Добавить ручную проверку обновлений и опциональную автопроверку клиента.
|
||||||
@@ -107,3 +109,9 @@
|
|||||||
|
|
||||||
## Ближайший следующий шаг
|
## Ближайший следующий шаг
|
||||||
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
Добавить профили пользователей, шифрование payload и инструментальные тесты BLE-обмена между несколькими устройствами.
|
||||||
|
|
||||||
|
## Ограничения сети
|
||||||
|
- Выделенный хост для NNNet не нужен: сеть строится как P2P mesh между устройствами.
|
||||||
|
- Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты.
|
||||||
|
- Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android.
|
||||||
|
- Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки.
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.schoolmesh.messenger"
|
namespace = "pro.nnnteam.nnnet"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.schoolmesh.messenger"
|
applicationId = "pro.nnnteam.nnnet"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 3
|
versionCode = 3
|
||||||
|
|||||||
@@ -24,7 +24,14 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SchoolMeshMessenger">
|
android:theme="@style/Theme.NNNet">
|
||||||
|
<activity
|
||||||
|
android:name=".SettingsActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ChatActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
218
android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt
Normal file
218
android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
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
|
||||||
|
val value = intent.getStringExtra(MeshServiceContract.EXTRA_EVENT_VALUE) ?: return
|
||||||
|
when (eventType) {
|
||||||
|
MeshServiceContract.EVENT_STATUS -> subtitleText.text = value
|
||||||
|
MeshServiceContract.EVENT_MESSAGES_CHANGED -> refreshMessages()
|
||||||
|
MeshServiceContract.EVENT_PEER -> if (value == peerId) {
|
||||||
|
subtitleText.text = getString(R.string.peer_nearby)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
titleText.text = peerId
|
||||||
|
subtitleText.text = getString(R.string.chat_waiting_status)
|
||||||
|
|
||||||
|
adapter = MessageListAdapter(this, messages)
|
||||||
|
messagesListView.adapter = adapter
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||||
|
findViewById<View>(R.id.sendButton).setOnClickListener { sendMessage() }
|
||||||
|
|
||||||
|
refreshMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
registerMeshReceiver()
|
||||||
|
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 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)
|
||||||
|
subtitleText.text = getString(R.string.message_sending)
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
356
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal file
356
android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
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.ChatSummary
|
||||||
|
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.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 chatSummaries = mutableListOf<ChatSummary>()
|
||||||
|
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 -> refreshChats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val permissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { result ->
|
||||||
|
val allGranted = result.values.all { it }
|
||||||
|
if (allGranted) {
|
||||||
|
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())
|
||||||
|
|
||||||
|
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, chatSummaries)
|
||||||
|
chatListView.adapter = chatAdapter
|
||||||
|
chatListView.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
openChat(chatSummaries[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_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_peer_id)
|
||||||
|
setSingleLine()
|
||||||
|
setPadding(48, 32, 48, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.new_chat_title)
|
||||||
|
.setView(input)
|
||||||
|
.setPositiveButton(R.string.open_chat) { _, _ ->
|
||||||
|
val peerId = input.text.toString().trim()
|
||||||
|
if (peerId.isNotEmpty()) {
|
||||||
|
openChat(peerId)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.peer_id_required, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
chatSummaries.clear()
|
||||||
|
chatSummaries.addAll(chats)
|
||||||
|
chatAdapter.notifyDataSetChanged()
|
||||||
|
emptyStateText.visibility = if (chats.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("останов")) {
|
||||||
|
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,98 @@
|
|||||||
|
package pro.nnnteam.nnnet
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pro.nnnteam.nnnet.update.UpdateInfo
|
||||||
|
import pro.nnnteam.nnnet.update.UpdateManager
|
||||||
|
|
||||||
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
private val prefs by lazy {
|
||||||
|
getSharedPreferences(UpdateManager.PREFS_NAME, MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_settings)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.backButton).setOnClickListener { finish() }
|
||||||
|
|
||||||
|
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<android.view.View>(R.id.checkUpdatesButton).setOnClickListener {
|
||||||
|
checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkForUpdates() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val updateInfo = withContext(Dispatchers.IO) { UpdateManager.fetchUpdateInfo() }
|
||||||
|
if (updateInfo == null) {
|
||||||
|
Toast.makeText(this@SettingsActivity, R.string.update_check_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (updateInfo.versionCode > currentVersionCode()) {
|
||||||
|
showUpdateDialog(updateInfo)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this@SettingsActivity, 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@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) { _, _ ->
|
||||||
|
val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath)
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
data class ChatSummary(
|
data class ChatSummary(
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import com.schoolmesh.messenger.mesh.MeshPacket
|
import pro.nnnteam.nnnet.mesh.MeshPacket
|
||||||
import com.schoolmesh.messenger.mesh.PacketType
|
import pro.nnnteam.nnnet.mesh.PacketType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class MeshRepository(
|
class MeshRepository(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.data
|
package pro.nnnteam.nnnet.data
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -69,22 +69,22 @@ class BleMeshManager(
|
|||||||
if (address == localNodeId || activeConnections.containsKey(address)) {
|
if (address == localNodeId || activeConnections.containsKey(address)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("Discovered BLE node: $address")
|
log("Обнаружен BLE-узел: $address")
|
||||||
connectToPeer(device)
|
connectToPeer(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScanFailed(errorCode: Int) {
|
override fun onScanFailed(errorCode: Int) {
|
||||||
fail("BLE scan failed: $errorCode")
|
fail("Ошибка BLE-сканирования: $errorCode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val advertiseCallback = object : AdvertiseCallback() {
|
private val advertiseCallback = object : AdvertiseCallback() {
|
||||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||||
log("BLE advertising started")
|
log("Запущен BLE advertising")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartFailure(errorCode: Int) {
|
override fun onStartFailure(errorCode: Int) {
|
||||||
fail("BLE advertising failed: $errorCode")
|
fail("Ошибка BLE advertising: $errorCode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,13 +187,13 @@ class BleMeshManager(
|
|||||||
fun start() {
|
fun start() {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
if (!hasRequiredRuntimePermissions()) {
|
if (!hasRequiredRuntimePermissions()) {
|
||||||
fail("BLE permissions are missing")
|
fail("Не выданы BLE-разрешения")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val adapter = bluetoothAdapter
|
val adapter = bluetoothAdapter
|
||||||
if (adapter == null || !adapter.isEnabled) {
|
if (adapter == null || !adapter.isEnabled) {
|
||||||
fail("Bluetooth adapter is unavailable or disabled")
|
fail("Bluetooth недоступен или выключен")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ class BleMeshManager(
|
|||||||
startScanning()
|
startScanning()
|
||||||
startAdvertising()
|
startAdvertising()
|
||||||
isRunning = true
|
isRunning = true
|
||||||
onStatusChanged("Mesh активен, идет discovery и GATT transport")
|
onStatusChanged("NNNet в сети, поиск соседей и транспорт GATT активны")
|
||||||
log("BLE mesh manager started with nodeId=$localNodeId")
|
log("BLE mesh manager started with nodeId=$localNodeId")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,34 +219,34 @@ class BleMeshManager(
|
|||||||
inboundCharacteristic = null
|
inboundCharacteristic = null
|
||||||
gattServer = null
|
gattServer = null
|
||||||
isRunning = false
|
isRunning = false
|
||||||
onStatusChanged("Mesh остановлен")
|
onStatusChanged("NNNet оффлайн")
|
||||||
log("BLE mesh manager stopped")
|
log("BLE mesh manager stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIncomingPacket(rawPacket: String) {
|
private fun handleIncomingPacket(rawPacket: String) {
|
||||||
val packet = runCatching { MeshPacketCodec.decode(rawPacket) }
|
val packet = runCatching { MeshPacketCodec.decode(rawPacket) }
|
||||||
.getOrElse {
|
.getOrElse {
|
||||||
fail("Packet decode failed: ${it.message}")
|
fail("Не удалось декодировать пакет: ${it.message}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val action = onPacketReceived(packet)) {
|
when (val action = onPacketReceived(packet)) {
|
||||||
MeshAction.DropDuplicate -> log("Duplicate packet dropped: ${packet.messageId}")
|
MeshAction.DropDuplicate -> log("Дубликат пакета отброшен: ${packet.messageId}")
|
||||||
MeshAction.DropExpired -> log("Expired packet dropped: ${packet.messageId}")
|
MeshAction.DropExpired -> log("Просроченный пакет отброшен: ${packet.messageId}")
|
||||||
is MeshAction.ConsumeAck -> {
|
is MeshAction.ConsumeAck -> {
|
||||||
onAckReceived(action.messageId)
|
onAckReceived(action.messageId)
|
||||||
log("ACK consumed: ${action.messageId}")
|
log("ACK обработан: ${action.messageId}")
|
||||||
}
|
}
|
||||||
is MeshAction.ConsumePresence -> {
|
is MeshAction.ConsumePresence -> {
|
||||||
onPeerDiscovered(action.senderId)
|
onPeerDiscovered(action.senderId)
|
||||||
onStatusChanged("Presence from ${action.senderId}")
|
onStatusChanged("Устройство ${action.senderId} рядом")
|
||||||
log("Presence consumed from ${action.senderId}")
|
log("Сигнал присутствия обработан от ${action.senderId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is MeshAction.ProcessAndRelay -> {
|
is MeshAction.ProcessAndRelay -> {
|
||||||
onMessageReceived(packet)
|
onMessageReceived(packet)
|
||||||
onStatusChanged("Message from ${packet.senderId}")
|
onStatusChanged("Новое сообщение от ${packet.senderId}")
|
||||||
log("Relaying packet ${packet.messageId}")
|
log("Ретрансляция пакета ${packet.messageId}")
|
||||||
broadcastPacket(action.packetToRelay)
|
broadcastPacket(action.packetToRelay)
|
||||||
sendAck(packet)
|
sendAck(packet)
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ class BleMeshManager(
|
|||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun startGattServer() {
|
private fun startGattServer() {
|
||||||
val manager = bluetoothManager ?: run {
|
val manager = bluetoothManager ?: run {
|
||||||
fail("BluetoothManager unavailable")
|
fail("BluetoothManager недоступен")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -9,9 +9,9 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.schoolmesh.messenger.R
|
import pro.nnnteam.nnnet.R
|
||||||
import com.schoolmesh.messenger.data.MeshDatabase
|
import pro.nnnteam.nnnet.data.MeshDatabase
|
||||||
import com.schoolmesh.messenger.data.MeshRepository
|
import pro.nnnteam.nnnet.data.MeshRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -33,7 +33,7 @@ class MeshForegroundService : Service() {
|
|||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
onPeerDiscovered = { address ->
|
onPeerDiscovered = { address ->
|
||||||
sendEvent(MeshServiceContract.EVENT_PEER, address)
|
sendEvent(MeshServiceContract.EVENT_PEER, address)
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Peer discovered: $address")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Устройство обнаружено: $address")
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
},
|
},
|
||||||
onStatusChanged = { status ->
|
onStatusChanged = { status ->
|
||||||
@@ -43,21 +43,21 @@ class MeshForegroundService : Service() {
|
|||||||
onAckReceived = { messageId ->
|
onAckReceived = { messageId ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
repository.markAckDelivered(messageId)
|
repository.markAckDelivered(messageId)
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "ACK delivered for $messageId")
|
sendEvent(MeshServiceContract.EVENT_LOG, "ACK получен для $messageId")
|
||||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessageReceived = { packet ->
|
onMessageReceived = { packet ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
repository.recordIncomingMessage(packet)
|
repository.recordIncomingMessage(packet)
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Message stored from ${packet.senderId}")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение сохранено от ${packet.senderId}")
|
||||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
|
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, packet.messageId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { message ->
|
onError = { message ->
|
||||||
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
sendEvent(MeshServiceContract.EVENT_STATUS, "Ошибка: $message")
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Error: $message")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Ошибка: $message")
|
||||||
updateNotification("Ошибка mesh")
|
updateNotification("Ошибка сети")
|
||||||
},
|
},
|
||||||
onLog = { message ->
|
onLog = { message ->
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, message)
|
sendEvent(MeshServiceContract.EVENT_LOG, message)
|
||||||
@@ -90,7 +90,7 @@ class MeshForegroundService : Service() {
|
|||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun startMesh() {
|
private fun startMesh() {
|
||||||
startForeground(NOTIFICATION_ID, buildNotification("Mesh запускается"))
|
startForeground(NOTIFICATION_ID, buildNotification("NNNet запускает сеть"))
|
||||||
bleMeshManager.start()
|
bleMeshManager.start()
|
||||||
queueProcessor.start()
|
queueProcessor.start()
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
@@ -107,7 +107,7 @@ class MeshForegroundService : Service() {
|
|||||||
val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty()
|
val targetId = intent.getStringExtra(MeshServiceContract.EXTRA_TARGET_ID)?.trim().orEmpty()
|
||||||
val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty()
|
val messageBody = intent.getStringExtra(MeshServiceContract.EXTRA_MESSAGE_BODY)?.trim().orEmpty()
|
||||||
if (targetId.isEmpty() || messageBody.isEmpty()) {
|
if (targetId.isEmpty() || messageBody.isEmpty()) {
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Cannot enqueue empty target/body")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Нельзя поставить в очередь пустое сообщение")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ class MeshForegroundService : Service() {
|
|||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
body = messageBody
|
body = messageBody
|
||||||
)
|
)
|
||||||
sendEvent(MeshServiceContract.EVENT_LOG, "Message queued: $messageId")
|
sendEvent(MeshServiceContract.EVENT_LOG, "Сообщение поставлено в очередь: $messageId")
|
||||||
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
sendEvent(MeshServiceContract.EVENT_MESSAGES_CHANGED, messageId)
|
||||||
queueProcessor.poke()
|
queueProcessor.poke()
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
import org.json.JSONObject
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
object MeshServiceContract {
|
object MeshServiceContract {
|
||||||
const val ACTION_START = "com.schoolmesh.messenger.mesh.START"
|
const val ACTION_START = "pro.nnnteam.nnnet.mesh.START"
|
||||||
const val ACTION_STOP = "com.schoolmesh.messenger.mesh.STOP"
|
const val ACTION_STOP = "pro.nnnteam.nnnet.mesh.STOP"
|
||||||
const val ACTION_SEND_MESSAGE = "com.schoolmesh.messenger.mesh.SEND_MESSAGE"
|
const val ACTION_SEND_MESSAGE = "pro.nnnteam.nnnet.mesh.SEND_MESSAGE"
|
||||||
const val ACTION_EVENT = "com.schoolmesh.messenger.mesh.EVENT"
|
const val ACTION_EVENT = "pro.nnnteam.nnnet.mesh.EVENT"
|
||||||
|
|
||||||
const val EXTRA_EVENT_TYPE = "event_type"
|
const val EXTRA_EVENT_TYPE = "event_type"
|
||||||
const val EXTRA_EVENT_VALUE = "event_value"
|
const val EXTRA_EVENT_VALUE = "event_value"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
enum class PacketType {
|
enum class PacketType {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.schoolmesh.messenger.mesh
|
package pro.nnnteam.nnnet.mesh
|
||||||
|
|
||||||
class SeenPacketCache(
|
class SeenPacketCache(
|
||||||
private val maxSize: Int = 512
|
private val maxSize: Int = 512
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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.ChatSummary
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ChatListAdapter(
|
||||||
|
context: Context,
|
||||||
|
private val items: MutableList<ChatSummary>
|
||||||
|
) : 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): ChatSummary = 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.peerId)
|
||||||
|
view.findViewById<TextView>(R.id.chatNameText).text = item.peerId
|
||||||
|
view.findViewById<TextView>(R.id.chatPreviewText).text = item.lastBody
|
||||||
|
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(peerId: String): String = peerId.firstOrNull()?.uppercase() ?: "N"
|
||||||
|
|
||||||
|
private fun statusLabel(status: String): String = when (status) {
|
||||||
|
"queued" -> "В очереди"
|
||||||
|
"sent" -> "Отправлено"
|
||||||
|
"delivered" -> "Доставлено"
|
||||||
|
"failed" -> "Ошибка"
|
||||||
|
else -> status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="#18222D"
|
android:background="@color/screen_background">
|
||||||
android:orientation="vertical"
|
|
||||||
tools:context=".MainActivity">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:background="#233040"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingVertical="14dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/titleText"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="NNNet"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
android:textSize="22sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/statusText"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="#2F6EA5"
|
|
||||||
android:paddingHorizontal="10dp"
|
|
||||||
android:paddingVertical="6dp"
|
|
||||||
android:text="Offline"
|
|
||||||
android:textColor="#FFFFFF" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="#233040"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingHorizontal="12dp"
|
|
||||||
android:paddingBottom="12dp">
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnTabChats"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="Chats" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnTabSettings"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="Settings" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/chatsScreen"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/top_bar_background"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingTop="18dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingBottom="14dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="NNNet"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="23sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/deviceCountText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:textColor="@color/top_bar_secondary_text"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/statusBadge"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:background="@drawable/bg_status_offline"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:foreground="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:paddingHorizontal="14dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusBadgeText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/menuButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/menu"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@android:drawable/ic_menu_more"
|
||||||
|
android:tint="@android:color/white" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="#121A23"
|
android:background="@color/top_bar_background"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingStart="16dp"
|
||||||
android:paddingVertical="10dp"
|
android:paddingBottom="14dp"
|
||||||
android:text="Chats"
|
android:text="@string/chats_title"
|
||||||
android:textColor="#8FA1B3"
|
android:textColor="@color/top_bar_secondary_text"
|
||||||
android:textStyle="bold" />
|
android:textSize="14sp" />
|
||||||
|
|
||||||
<ListView
|
<ListView
|
||||||
android:id="@+id/chatListView"
|
android:id="@+id/chatListView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="180dp"
|
|
||||||
android:background="#10161E"
|
|
||||||
android:divider="#1A2531"
|
|
||||||
android:dividerHeight="1dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="#1F2C39"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="14dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/activeChatTitle"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Select a chat"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
android:textSize="18sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/peersText"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:text="Nearby peers will appear here"
|
|
||||||
android:textColor="#8FA1B3" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<ListView
|
|
||||||
android:id="@+id/messageListView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:background="#0E1621"
|
android:background="@color/screen_background"
|
||||||
android:divider="@android:color/transparent"
|
android:divider="@color/chat_divider"
|
||||||
android:dividerHeight="8dp"
|
android:dividerHeight="1dp"
|
||||||
android:padding="12dp"
|
android:listSelector="@android:color/transparent"
|
||||||
android:transcriptMode="alwaysScroll" />
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="92dp"
|
||||||
|
android:scrollbars="none" />
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
|
android:id="@+id/emptyStateText"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="#18222D"
|
android:layout_gravity="center"
|
||||||
android:orientation="vertical"
|
android:gravity="center"
|
||||||
android:padding="12dp">
|
android:padding="24dp"
|
||||||
|
android:text="@string/no_chats"
|
||||||
<EditText
|
android:textColor="@color/secondary_text"
|
||||||
android:id="@+id/targetInput"
|
android:visibility="gone" />
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="#233040"
|
|
||||||
android:hint="Peer ID"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
android:textColorHint="#8FA1B3" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/messageInput"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:background="#233040"
|
|
||||||
android:hint="Write a message"
|
|
||||||
android:minLines="2"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
android:textColorHint="#8FA1B3" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnSendMessage"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
android:text="Send" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<ScrollView
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
android:id="@+id/settingsScreen"
|
android:id="@+id/newChatButton"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_gravity="bottom|end"
|
||||||
android:visibility="gone">
|
android:layout_margin="20dp"
|
||||||
|
android:contentDescription="@string/new_chat"
|
||||||
<LinearLayout
|
android:src="@android:drawable/ic_input_add"
|
||||||
android:layout_width="match_parent"
|
app:backgroundTint="@color/fab_background"
|
||||||
android:layout_height="wrap_content"
|
app:tint="@android:color/white" />
|
||||||
android:orientation="vertical"
|
</FrameLayout>
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Settings"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
|
||||||
android:id="@+id/autoUpdateSwitch"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="20dp"
|
|
||||||
android:text="Enable auto update checks"
|
|
||||||
android:textColor="#F3F7FB"
|
|
||||||
app:useMaterialThemeColors="false" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnCheckUpdates"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="12dp"
|
|
||||||
android:text="Check updates now" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnStartMesh"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="12dp"
|
|
||||||
android:text="Start mesh" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btnStopMesh"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="12dp"
|
|
||||||
android:text="Stop mesh" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="24dp"
|
|
||||||
android:text="Event log"
|
|
||||||
android:textColor="#8FA1B3"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/logsText"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:background="#101820"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:text="Log is empty"
|
|
||||||
android:textColor="#EAF7F2"
|
|
||||||
android:textIsSelectable="true" />
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
</LinearLayout>
|
|
||||||
|
|||||||
72
android/app/src/main/res/layout/activity_settings.xml
Normal file
72
android/app/src/main/res/layout/activity_settings.xml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?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>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/versionText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
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" />
|
||||||
|
</LinearLayout>
|
||||||
|
</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>
|
||||||
6
android/app/src/main/res/menu/main_menu.xml
Normal file
6
android/app/src/main/res/menu/main_menu.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/menu_settings"
|
||||||
|
android:title="@string/settings_title" />
|
||||||
|
</menu>
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="teal_primary">#1E6E54</color>
|
<color name="screen_background">#F4F6F8</color>
|
||||||
<color name="teal_container">#A4F3D5</color>
|
<color name="chat_background">#D9EAF4</color>
|
||||||
<color name="blue_secondary">#1150B4</color>
|
<color name="top_bar_background">#527DA3</color>
|
||||||
|
<color name="top_bar_secondary_text">#D8E6F1</color>
|
||||||
|
<color name="primary_text">#1E2B37</color>
|
||||||
|
<color name="secondary_text">#72879A</color>
|
||||||
|
<color name="accent_blue">#4C9EEB</color>
|
||||||
|
<color name="fab_background">#4C9EEB</color>
|
||||||
|
<color name="chat_divider">#DDE4EA</color>
|
||||||
|
<color name="message_input_panel">#EDF2F6</color>
|
||||||
|
<color name="message_meta">#6C7E8F</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">NNNet</string>
|
<string name="app_name">NNNet</string>
|
||||||
<string name="notification_title">NNNet</string>
|
<string name="notification_title">NNNet</string>
|
||||||
<string name="notification_channel_name">Mesh status</string>
|
<string name="notification_channel_name">Статус mesh-сети</string>
|
||||||
|
<string name="menu">Меню</string>
|
||||||
|
<string name="settings_title">Настройки</string>
|
||||||
|
<string name="chats_title">Чаты</string>
|
||||||
|
<string name="status_online">В сети</string>
|
||||||
|
<string name="status_offline">Не в сети</string>
|
||||||
|
<string name="total_devices">Устройств: %1$d</string>
|
||||||
|
<string name="no_chats">Чатов пока нет. Нажмите +, чтобы открыть новый диалог.</string>
|
||||||
|
<string name="new_chat">Новый чат</string>
|
||||||
|
<string name="new_chat_title">Новый диалог</string>
|
||||||
|
<string name="hint_peer_id">Идентификатор устройства</string>
|
||||||
|
<string name="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="update_check_failed">Не удалось проверить обновления</string>
|
||||||
|
<string name="latest_version_installed">У вас уже установлена последняя версия</string>
|
||||||
|
<string name="update_available_message">Доступна версия %1$s.</string>
|
||||||
|
<string name="download_update">Скачать обновление</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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<style name="Theme.SchoolMeshMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.NNNet" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">#1E6E54</item>
|
<item name="colorPrimary">#4C9EEB</item>
|
||||||
<item name="colorPrimaryContainer">#A4F3D5</item>
|
<item name="colorPrimaryContainer">#A8D6FA</item>
|
||||||
<item name="colorSecondary">#1150B4</item>
|
<item name="colorSecondary">#527DA3</item>
|
||||||
|
<item name="android:statusBarColor">#527DA3</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -3,11 +3,23 @@
|
|||||||
## Слои
|
## Слои
|
||||||
- BLE Transport: сканирование, реклама, соединения, обмен пакетами.
|
- BLE Transport: сканирование, реклама, соединения, обмен пакетами.
|
||||||
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK.
|
- Mesh Layer: маршрутизация, TTL, дедупликация, ACK.
|
||||||
- Messaging Layer: список чатов, диалог, статусы доставки, история.
|
- Messaging Layer: список чатов, отдельный экран диалога, статусы доставки, история.
|
||||||
- Storage Layer: Room для локального хранения.
|
- Storage Layer: Room для локального хранения.
|
||||||
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
- Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса.
|
||||||
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
- Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента.
|
||||||
|
|
||||||
|
## Пользовательский сценарий
|
||||||
|
- Главный экран показывает список чатов в стиле Telegram.
|
||||||
|
- Верхний статусный блок переключает mesh-сеть между состояниями `В сети` и `Не в сети`.
|
||||||
|
- Слева в шапке показывается общее количество известных устройств в mesh.
|
||||||
|
- Настройки вынесены в меню `три точки`, отдельный debug-лог из пользовательского интерфейса убран.
|
||||||
|
- Отправка сообщений доступна только из экрана конкретного диалога.
|
||||||
|
|
||||||
|
## Топология сети
|
||||||
|
- Выделенный сервер или хост для работы mesh не нужен.
|
||||||
|
- Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором.
|
||||||
|
- Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону.
|
||||||
|
|
||||||
## Сетевой пакет (черновик)
|
## Сетевой пакет (черновик)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
releaseNotesText.innerHTML = notes.trim().replace(/\n/g, '<br>');
|
releaseNotesText.innerHTML = notes.trim().replace(/\n/g, '<br>');
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
releaseNotesText.textContent = 'Не удалось загрузить changelog.';
|
releaseNotesText.textContent = 'Не удалось загрузить описание обновления.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,9 +40,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="mb-4">Что внутри</h2>
|
<h2 class="mb-4">Что внутри</h2>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE Discovery</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-broadcast-pin fs-2"></i><h5 class="mt-2">BLE-поиск</h5><p class="mb-0">Обнаружение ближайших узлов и обмен пакетами без интернета.</p></div></div>
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh Relay</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и retry queue.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-share fs-2"></i><h5 class="mt-2">Mesh-ретрансляция</h5><p class="mb-0">Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.</p></div></div>
|
||||||
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Local Storage</h5><p class="mb-0">Room хранит историю сообщений и очередь исходящей доставки.</p></div></div>
|
<div class="col-md-4"><div class="feature p-3 rounded-4"><i class="bi bi-database fs-2"></i><h5 class="mt-2">Локальное хранение</h5><p class="mb-0">Room хранит историю сообщений и очередь исходящей доставки.</p></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -65,6 +65,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="assets/js/app.js?v=3"></script>
|
<script src="assets/js/app.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user